From f92fa65c707d772d06e076797d6f2d48d5b975d7 Mon Sep 17 00:00:00 2001 From: joaner <1726541+joaner@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:54:16 +0800 Subject: [PATCH 1/6] Prepare stable open-source release Harden LeRobot compatibility, freeze the viewer API, and add the quality, security, documentation, and release gates required for a reliable 1.0 launch. --- .changeset/README.md | 5 + .changeset/config.json | 11 + .github/PULL_REQUEST_TEMPLATE.md | 5 +- .github/workflows/ci.yml | 49 +- .github/workflows/codeql.yml | 42 + .github/workflows/dependency-review.yml | 26 + .github/workflows/docs-pages.yml | 57 + .github/workflows/nightly.yml | 61 +- .github/workflows/release-dry-run.yml | 45 + .github/workflows/release.yml | 53 +- .github/workflows/scorecard.yml | 39 + .gitignore | 3 + CHANGELOG.md | 15 +- CODEOWNERS | 13 + CODE_OF_CONDUCT.md | 21 +- CONTRIBUTING.md | 42 +- GOVERNANCE.md | 71 + NOTICE | 13 +- README.md | 106 +- SECURITY.md | 71 +- SUPPORT.md | 51 + api-extractor.json | 33 + components.json | 10 +- docs/.vitepress/config.ts | 70 + docs/api.md | 54 + docs/architecture.md | 33 +- docs/browser.md | 38 + docs/compatibility.md | 114 + docs/cors.md | 50 + docs/data-formats.md | 48 + docs/deployment.md | 77 +- docs/deprecation.md | 28 + docs/development.md | 57 + docs/github-migration.md | 58 +- docs/index.md | 36 + docs/migration.md | 47 + docs/privacy.md | 55 + docs/quick-start.md | 76 + docs/troubleshooting.md | 58 + eslint.config.js | 23 +- etc/lerobot-studio.api.md | 70 + examples/next-app-router/app/layout.tsx | 18 + examples/next-app-router/app/page.tsx | 5 + examples/next-app-router/app/styles.css | 7 + .../components/ViewerCanvas.tsx | 55 + .../components/ViewerClient.tsx | 12 + examples/next-app-router/next-env.d.ts | 4 + examples/next-app-router/package.json | 23 + examples/next-app-router/tsconfig.json | 20 + examples/vite-react/index.html | 12 + examples/vite-react/package.json | 24 + examples/vite-react/src/App.tsx | 57 + examples/vite-react/src/main.tsx | 44 + examples/vite-react/src/styles.css | 8 + examples/vite-react/tsconfig.json | 17 + examples/vite-react/vite.config.ts | 7 + package-lock.json | 3677 +++++++++++++++-- package.json | 48 +- scripts/check-bundle-size.mjs | 283 ++ scripts/generate-test-fixtures.mjs | 112 +- scripts/verify-npm-consumer.mjs | 310 +- src/core/analysis/SplitService.ts | 21 +- src/core/analysis/StatsService.ts | 584 ++- src/core/datasource/types.ts | 13 + src/core/export/types.ts | 16 +- src/core/index.ts | 14 +- src/core/types/lerobot.ts | 15 +- src/core/versioning/LeRobotVersionAdapter.ts | 2 +- src/core/versioning/arrowUtils.ts | 10 +- src/core/versioning/index.ts | 8 + src/core/versioning/v2Adapter.ts | 25 +- src/core/versioning/v2FormatValidator.ts | 30 +- src/core/versioning/v3Adapter.ts | 51 +- src/core/versioning/v3FormatValidator.ts | 29 +- src/core/versioning/validation.ts | 2 + src/core/versioning/versionCapability.ts | 48 + src/core/versioning/versionRegistry.ts | 21 +- .../datasource/ArchiveDataSourceFactory.ts | 26 +- .../datasource/RemoteManifestDataSource.ts | 130 +- src/platform/datasource/dataSources.ts | 418 +- src/platform/datasource/inputSafety.ts | 185 + src/platform/datasource/remotePreflight.ts | 38 +- src/platform/export/DataProcessor.ts | 292 +- src/platform/export/ExportService.ts | 166 +- src/platform/export/MetadataExporter.ts | 370 +- src/platform/export/TaskPlan.ts | 80 + src/platform/export/VideoExporter.ts | 148 +- src/platform/services/LeRobotDataLoader.ts | 28 +- src/platform/workers/wasmUrl.inline.ts | 1 - src/platform/workers/workerManager.ts | 29 +- src/react/components/Commons/ErrorState.tsx | 7 +- src/react/components/ErrorBoundary.tsx | 77 +- src/react/components/LeRobotViewer.tsx | 157 +- src/react/components/Navbar.tsx | 20 +- src/react/components/PanelTitle.tsx | 4 +- src/react/components/Playback/PlaybackBar.tsx | 7 +- .../Sidebar/AnalysisSidebarContent.tsx | 7 +- .../components/Sidebar/EpisodeSidebar.tsx | 111 +- .../Sidebar/episodes/EpisodeHeader.tsx | 112 +- .../Sidebar/episodes/EpisodeList.tsx | 130 +- src/react/components/dialogs/ExportDialog.tsx | 60 +- .../components/open/SampleDatasetCard.tsx | 11 +- .../ChartPanel/Split/SplitJointMiniPlot.tsx | 5 +- .../panels/Common/MediaDebugOverlay.tsx | 5 +- .../panels/Common/PanelErrorBoundary.tsx | 80 +- .../components/panels/Common/PanelState.tsx | 7 +- .../panels/ImagePanel/ImagePanel.tsx | 17 +- .../panels/VideoPanel/VideoPanel.tsx | 16 +- src/react/contexts/LeRobotContext.ts | 10 +- src/react/contexts/LeRobotProvider.tsx | 476 +-- src/react/contexts/useEpisodeView.ts | 129 + src/react/contexts/useFeatureSubscriptions.ts | 230 ++ src/react/contexts/usePlaybackBridge.ts | 172 + src/react/contexts/versionMutationPolicy.ts | 17 + src/react/features/viewer/ViewerLayout.tsx | 28 +- src/react/hooks/useKeyboardShortcuts.ts | 19 +- src/react/i18n/core.tsx | 8 +- src/react/index.ts | 103 +- src/react/internal.ts | 52 + src/react/locales/en.json | 14 + src/react/locales/ja.json | 14 + src/react/locales/zh.json | 14 + src/ui/components/ui/dropdown-menu.tsx | 7 +- tests/archive-datasource.spec.ts | 339 ++ tests/browser/a11y.ts | 32 + .../browser-compatibility.browser.spec.tsx | 198 + .../frontend-experience.browser.spec.tsx | 169 + .../read-only-episodes.browser.spec.tsx | 94 + tests/browser/ui-primitives.browser.spec.tsx | 44 +- .../compat/official-dataset-roundtrip.spec.ts | 104 +- .../official-metadata-roundtrip.spec.ts | 50 +- tests/datasource-input-safety.spec.ts | 227 + tests/episode-view.spec.ts | 49 + tests/export-data.spec.ts | 210 +- tests/export-metadata.spec.ts | 268 +- tests/export-service.spec.ts | 307 +- tests/export-video.spec.ts | 49 + tests/feature-subscription-batcher.spec.ts | 105 + .../lerobotv2-image/meta/episodes_stats.jsonl | 2 + .../lerobotv2/meta/episodes_stats.jsonl | 2 + .../meta/episodes/chunk-000/file-000.parquet | Bin 3772 -> 10678 bytes tests/frontend-performance.spec.ts | 136 + tests/helpers/officialStatsOracle.py | 61 + tests/loader-lifecycle.spec.ts | 261 ++ tests/playback-bridge.spec.ts | 44 + tests/remote-preflight.spec.ts | 237 ++ tests/split-service.spec.ts | 115 + tests/stats-service-v061.spec.ts | 221 + tests/versioning-stats-correctness.spec.ts | 127 + tsconfig.app.json | 2 +- vite.config.ts | 2 +- vite.lib.config.ts | 48 +- vitest.browser.smoke.config.ts | 54 + vitest.config.ts | 42 +- 154 files changed, 13252 insertions(+), 1850 deletions(-) create mode 100644 .changeset/README.md create mode 100644 .changeset/config.json create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/dependency-review.yml create mode 100644 .github/workflows/docs-pages.yml create mode 100644 .github/workflows/release-dry-run.yml create mode 100644 .github/workflows/scorecard.yml create mode 100644 CODEOWNERS create mode 100644 GOVERNANCE.md create mode 100644 SUPPORT.md create mode 100644 api-extractor.json create mode 100644 docs/.vitepress/config.ts create mode 100644 docs/api.md create mode 100644 docs/browser.md create mode 100644 docs/compatibility.md create mode 100644 docs/cors.md create mode 100644 docs/data-formats.md create mode 100644 docs/deprecation.md create mode 100644 docs/index.md create mode 100644 docs/migration.md create mode 100644 docs/privacy.md create mode 100644 docs/quick-start.md create mode 100644 docs/troubleshooting.md create mode 100644 etc/lerobot-studio.api.md create mode 100644 examples/next-app-router/app/layout.tsx create mode 100644 examples/next-app-router/app/page.tsx create mode 100644 examples/next-app-router/app/styles.css create mode 100644 examples/next-app-router/components/ViewerCanvas.tsx create mode 100644 examples/next-app-router/components/ViewerClient.tsx create mode 100644 examples/next-app-router/next-env.d.ts create mode 100644 examples/next-app-router/package.json create mode 100644 examples/next-app-router/tsconfig.json create mode 100644 examples/vite-react/index.html create mode 100644 examples/vite-react/package.json create mode 100644 examples/vite-react/src/App.tsx create mode 100644 examples/vite-react/src/main.tsx create mode 100644 examples/vite-react/src/styles.css create mode 100644 examples/vite-react/tsconfig.json create mode 100644 examples/vite-react/vite.config.ts create mode 100644 scripts/check-bundle-size.mjs create mode 100644 src/core/versioning/versionCapability.ts create mode 100644 src/platform/datasource/inputSafety.ts create mode 100644 src/platform/export/TaskPlan.ts delete mode 100644 src/platform/workers/wasmUrl.inline.ts create mode 100644 src/react/contexts/useEpisodeView.ts create mode 100644 src/react/contexts/useFeatureSubscriptions.ts create mode 100644 src/react/contexts/usePlaybackBridge.ts create mode 100644 src/react/contexts/versionMutationPolicy.ts create mode 100644 src/react/internal.ts create mode 100644 tests/archive-datasource.spec.ts create mode 100644 tests/browser/a11y.ts create mode 100644 tests/browser/browser-compatibility.browser.spec.tsx create mode 100644 tests/browser/frontend-experience.browser.spec.tsx create mode 100644 tests/browser/read-only-episodes.browser.spec.tsx create mode 100644 tests/datasource-input-safety.spec.ts create mode 100644 tests/episode-view.spec.ts create mode 100644 tests/export-video.spec.ts create mode 100644 tests/feature-subscription-batcher.spec.ts create mode 100644 tests/fixtures/datasets/lerobotv2-image/meta/episodes_stats.jsonl create mode 100644 tests/fixtures/datasets/lerobotv2/meta/episodes_stats.jsonl create mode 100644 tests/frontend-performance.spec.ts create mode 100644 tests/helpers/officialStatsOracle.py create mode 100644 tests/loader-lifecycle.spec.ts create mode 100644 tests/playback-bridge.spec.ts create mode 100644 tests/split-service.spec.ts create mode 100644 tests/stats-service-v061.spec.ts create mode 100644 tests/versioning-stats-correctness.spec.ts create mode 100644 vitest.browser.smoke.config.ts diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..631398b --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,5 @@ +# Changesets + +Run `npm run changeset` for every user-visible package change. Commit the generated +markdown file with the implementation so release versioning and changelog updates +remain reviewable. diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..2be13d4 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [] +} diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 144ec59..3ff3800 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -3,7 +3,10 @@ ## Checklist - [ ] `npm run lint` passes +- [ ] `npm run typecheck` passes - [ ] `npm run test:unit` passes -- [ ] Relevant builds (`build:web` / `build:lib`) pass +- [ ] Relevant builds (`build` / `build:lib`) pass +- [ ] `npm run check:package` passes after `npm run build:lib` - [ ] Added a changeset when publishing API changes +- [ ] Updated the API report when the stable public API changed - [ ] Updated docs when behavior or public API changed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eeab96c..b640708 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,16 @@ name: CI on: + workflow_call: push: branches: [main, master] pull_request: +permissions: + contents: read + concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: @@ -14,8 +18,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version-file: '.nvmrc' cache: npm @@ -33,41 +39,56 @@ jobs: run: npm run test:coverage - name: Build library run: npm run build:lib - - name: Verify npm consumer typecheck - run: npm run verify:npm-consumer + - name: Verify publishable package and API report + run: npm run check:package + - name: Validate CycloneDX SBOM generation + run: npm run sbom - name: Build web app run: npm run build + - name: Build documentation + run: npm run docs:build + - name: Check npm and web bundle sizes + run: npm run check:bundle-size browser-tests: runs-on: ubuntu-latest timeout-minutes: 60 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version-file: '.nvmrc' cache: npm - run: npm ci - - run: npx playwright install --with-deps chromium + - name: Install browser engines + run: npx playwright install --with-deps chromium firefox webkit - name: Generate test fixtures run: npm run fixtures:generate - - run: npm run test:browser + - name: Chromium full browser suite + run: npm run test:browser + - name: Cross-browser compatibility smoke + run: npm run test:browser:smoke official-compat: runs-on: ubuntu-latest timeout-minutes: 45 steps: - - uses: actions/checkout@v4 - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: repository: huggingface/lerobot - ref: adccdea1cfbec83ed98263feb7e59f7d047c5692 + ref: 7e241bd630a3719a56157a497ce5d08f244784f1 # v0.6.1 path: official-lerobot - - uses: actions/setup-node@v4 + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version-file: '.nvmrc' cache: npm - - uses: astral-sh/setup-uv@v6 + - uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6 - run: npm ci - name: Generate test fixtures run: npm run fixtures:generate diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..7c85061 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,42 @@ +name: CodeQL + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + schedule: + - cron: '17 4 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: codeql-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + language: [javascript-typescript] + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Initialize CodeQL + uses: github/codeql-action/init@422a57891394145295c3272d7658d7c490278772 # v4.37.4 + with: + languages: ${{ matrix.language }} + - name: Analyze + uses: github/codeql-action/analyze@422a57891394145295c3272d7658d7c490278772 # v4.37.4 + with: + category: /language:${{ matrix.language }} diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..85a9849 --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,26 @@ +name: Dependency Review + +on: + pull_request: + branches: [main, master] + +permissions: + contents: read + +concurrency: + group: dependency-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + dependency-review: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Review dependency changes + uses: actions/dependency-review-action@d02fa39f790d6e8a4ecafab5848251ff12c20df7 # v4.9.0 + with: + fail-on-severity: moderate + comment-summary-in-pr: never diff --git a/.github/workflows/docs-pages.yml b/.github/workflows/docs-pages.yml new file mode 100644 index 0000000..88bf6db --- /dev/null +++ b/.github/workflows/docs-pages.yml @@ -0,0 +1,57 @@ +name: Documentation Pages + +on: + push: + branches: [main] + paths: + - 'docs/**' + - 'etc/lerobot-studio.api.md' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/docs-pages.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version-file: '.nvmrc' + cache: npm + - run: npm ci + - name: Build documentation for project Pages + env: + DOCS_BASE: /${{ github.event.repository.name }}/ + run: npm run docs:build + - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 + with: + path: docs/.vitepress/dist + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pages: write + id-token: write + steps: + - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 + - name: Deploy documentation + id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5 diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index f56b9d6..bf60b4e 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -5,32 +5,79 @@ on: - cron: '0 3 * * *' workflow_dispatch: +permissions: + contents: read + +concurrency: + group: nightly-${{ github.ref }} + cancel-in-progress: true + jobs: browser: runs-on: ubuntu-latest timeout-minutes: 60 steps: - - uses: actions/checkout@v4 - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: repository: huggingface/lerobot - ref: adccdea1cfbec83ed98263feb7e59f7d047c5692 + ref: 7e241bd630a3719a56157a497ce5d08f244784f1 # v0.6.1 path: official-lerobot - - uses: astral-sh/setup-uv@v6 - - uses: actions/setup-node@v4 + persist-credentials: false + - uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version-file: '.nvmrc' cache: npm - run: npm ci + - name: Generate test fixtures + run: npm run fixtures:generate - name: Install official LeRobot dataset reader working-directory: official-lerobot run: uv sync --extra dataset - - run: npx playwright install --with-deps chromium + - name: Install browser engines + run: npx playwright install --with-deps chromium firefox webkit - run: npm run test:unit - - run: npm run test:browser + - name: Chromium full browser suite + run: npm run test:browser + - name: Cross-browser compatibility smoke + run: npm run test:browser:smoke - name: Official LeRobot metadata compatibility run: npx vitest run tests/compat env: LEROBOT_PYTHON: ${{ github.workspace }}/official-lerobot/.venv/bin/python - run: npm run build - run: npm run build:lib + + upstream-main-compat: + name: Upstream main compatibility (warning) + continue-on-error: true + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + repository: huggingface/lerobot + ref: main + path: official-lerobot + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version-file: '.nvmrc' + cache: npm + - uses: astral-sh/setup-uv@d0d8abe699bfb85fec6de9f7adb5ae17292296ff # v6 + - run: npm ci + - name: Generate test fixtures + run: npm run fixtures:generate + - name: Install upstream LeRobot dataset reader + working-directory: official-lerobot + run: uv sync --extra dataset + - name: Check compatibility with upstream main + env: + LEROBOT_PYTHON: ${{ github.workspace }}/official-lerobot/.venv/bin/python + run: npm run test:compat diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml new file mode 100644 index 0000000..f0e0f9a --- /dev/null +++ b/.github/workflows/release-dry-run.yml @@ -0,0 +1,45 @@ +name: Release Dry Run + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: release-dry-run-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + uses: ./.github/workflows/ci.yml + permissions: + contents: read + + artifacts: + needs: ci + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version-file: '.nvmrc' + cache: npm + - run: npm ci + - run: npm run build:lib + - name: Validate npm package without publishing + run: npm publish --dry-run --access public + - name: Generate CycloneDX SBOM + run: npm run sbom + - name: Build container without pushing + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 + with: + context: . + push: false + provenance: mode=max + sbom: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d97e22f..a020729 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,27 +7,38 @@ on: workflow_dispatch: permissions: - contents: write - packages: write - id-token: write + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: + ci: + uses: ./.github/workflows/ci.yml + permissions: + contents: read + release: + needs: ci runs-on: ubuntu-latest timeout-minutes: 60 + permissions: + contents: write + packages: write + id-token: write steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version-file: '.nvmrc' cache: npm registry-url: https://registry.npmjs.org - run: npm ci - - run: npx playwright install --with-deps chromium - - run: npm run test:unit - - run: npm run test:browser - run: npm run build:lib - - run: npm run build + - run: npm run sbom - name: Verify tag matches npm package version env: TAG_VERSION: ${{ github.ref_name }} @@ -38,14 +49,23 @@ jobs: echo "Tag $TAG_VERSION must match package version $expected_tag" >&2 exit 1 } - - name: Publish npm package + - name: Bootstrap first npm publish + if: github.ref_name == 'v1.0.0' env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_BOOTSTRAP_TOKEN }} + run: | + test -n "$NODE_AUTH_TOKEN" || { + echo "NPM_BOOTSTRAP_TOKEN is required only for the first package publication" >&2 + exit 1 + } + npm publish --access public --provenance + - name: Publish npm package with trusted publishing + if: github.ref_name != 'v1.0.0' run: npm publish --access public --provenance - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Login to GHCR - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -54,15 +74,18 @@ jobs: id: meta run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" - name: Build and push Docker image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . push: true + provenance: mode=max + sbom: true platforms: linux/amd64,linux/arm64 tags: | ghcr.io/ioai-tech/lerobot-studio:latest ghcr.io/ioai-tech/lerobot-studio:${{ steps.meta.outputs.version }} - name: GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: generate_release_notes: true + files: sbom.cdx.json diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml new file mode 100644 index 0000000..2120bb7 --- /dev/null +++ b/.github/workflows/scorecard.yml @@ -0,0 +1,39 @@ +name: OpenSSF Scorecard + +on: + branch_protection_rule: + schedule: + - cron: '23 5 * * 2' + push: + branches: [main, master] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: scorecard-${{ github.ref }} + cancel-in-progress: true + +jobs: + scorecard: + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write + security-events: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Run Scorecard analysis + uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + - name: Upload Scorecard results to code scanning + uses: github/codeql-action/upload-sarif@422a57891394145295c3272d7658d7c490278772 # v4.37.4 + with: + sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index a89a4a1..07d29a3 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ dist-ssr dist-lib dev-dist coverage +docs/.vitepress/cache +/temp/ *.local .env .env.* @@ -20,6 +22,7 @@ coverage .vitest-attachments/ tests/browser/__screenshots__/ *.tgz +sbom.cdx.json # Editor directories and files .vscode/* diff --git a/CHANGELOG.md b/CHANGELOG.md index dfac3a3..4ecc910 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Single-package source layout with logical `core`, `platform`, `ui`, `react`, and `web` boundaries -- Published package identity `@ioai/lerobot-studio` (MIT) +- Package identity `@ioai/lerobot-studio` (MIT) - Shared `ViewerLayout` for app and library shells - `LeRobotStudioProvider` public composition root - Minimal synthetic LeRobot v2/v3 fixtures under `tests/fixtures/datasets` - GitHub Actions CI, nightly browser tests, and release workflow (npm + GHCR) +- Governance, support, security response targets, privacy disclosure, + compatibility matrix, CODEOWNERS, and deprecation policy + +### Changed + +- Defined the stable 1.0 library contract as viewer-only, ESM/CSR-only, + browser-only, React `^19.0.0`, zero telemetry, and backward compatible within + major version 1 +- Defined exact LeRobot `v2.1` and `v3.0` support; other declared versions are + unknown rather than prefix-compatible +- Classified unknown-version read-only warnings, blocked export, browser + validation, consumer validation, and official training-ready export as 1.0 + release gates, not current verified capabilities ### Removed diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 0000000..cd1ee19 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,13 @@ +# Replace this planned team if the public repository uses a different +# write-enabled maintainer team. GitHub does not enforce this file until the +# referenced team exists and branch protection requires CODEOWNERS review. +* @ioai-tech/maintainers + +/SECURITY.md @ioai-tech/maintainers +/GOVERNANCE.md @ioai-tech/maintainers +/CODE_OF_CONDUCT.md @ioai-tech/maintainers +/CODEOWNERS @ioai-tech/maintainers +/NOTICE @ioai-tech/maintainers +/docs/compatibility.md @ioai-tech/maintainers +/docs/deprecation.md @ioai-tech/maintainers +/docs/privacy.md @ioai-tech/maintainers diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 0b0a632..c0d84b4 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -21,12 +21,21 @@ Examples of unacceptable behavior include: ## Enforcement -Report unacceptable behavior to the maintainers through -[GitHub Issues](https://github.com/ioai-tech/lerobot-studio/issues). Do not use -public issues to report security vulnerabilities; follow [SECURITY.md](./SECURITY.md) -instead. Maintainers will review reports and respond as appropriate. +Do not place sensitive conduct reports, personal information, or evidence in a +public issue. Email `io@io-ai.tech` with the subject “LeRobot Studio conduct +report”, or contact an IO-AI.TECH organization maintainer privately through +their GitHub profile. Share only the information needed to assess the report. + +Maintainers will protect reporter privacy as far as practical, disclose +information only to those needed for review, and avoid retaliation. A +maintainer who is involved in a report must recuse themselves. Responses may +include a warning, content removal, temporary restriction, permanent ban, or +referral to the relevant platform or authority. + +Do not use conduct reporting for security vulnerabilities; follow +[SECURITY.md](./SECURITY.md) instead. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), -version 2.1. +This Code of Conduct is adapted from the +[Contributor Covenant](https://www.contributor-covenant.org), version 2.1. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4e5492..ba23097 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,11 +10,47 @@ npm run fixtures:generate npm run dev ``` -Before a pull request, run `npm run format:check`, `npm run lint`, `npm run typecheck`, `npm run test:unit`, and the relevant build target. +Before a pull request, run `npm run format:check`, `npm run lint`, +`npm run typecheck`, `npm run test:unit`, and the relevant build target. Run +browser, compatibility, and npm-consumer checks when the change affects those +surfaces. ## Architecture guidelines -The repository has one publishable package. Keep the dependency direction `web → react → platform → core`; UI is shared by React and the app. `core` remains free of React and browser APIs, while WASM, workers, and file-system integration stay in `src/platform`. The public npm API is `src/react/index.ts`. +The repository has one publishable package. Keep the dependency direction +`web → react → platform → core`; UI is shared by React and the app. `core` +remains free of React and browser APIs, while WASM, workers, and file-system +integration stay in `src/platform`. The public npm API is +`src/react/index.ts`. + +The stable 1.0 library is viewer-only, ESM/CSR-only, browser-only, and targets +React `^19.0.0`. Do not add editing or export services to the stable public API, +SSR/RSC paths, CommonJS output, telemetry, or undocumented network calls. + +## Compatibility and data integrity + +Read [Compatibility](./docs/compatibility.md) before changing loaders, +validators, media handling, export, packaging, or browser APIs. + +- Treat dataset input as untrusted and preserve source data. +- Support LeRobot `v2.1` and `v3.0` by exact declared version only. +- Keep unknown versions warning-marked and read-only; export must be blocked in + both UI and service layers. +- Do not call export supported until official LeRobot training-readiness tests + pass for the complete output. +- Add fixtures and regression tests for behavior changes. Record actual browser + versions for browser compatibility claims. + +## Pull requests + +Keep changes focused and explain user-visible behavior, compatibility impact, +test evidence, privacy/security impact, and migration steps. Public API changes +must follow the [deprecation policy](./docs/deprecation.md). A maintainer and +required CODEOWNERS approve changes; authors do not approve their own pull +requests. + +By contributing, you agree that your contribution is licensed under the +repository’s [MIT License](./LICENSE). ## Code of conduct @@ -23,3 +59,5 @@ Please follow [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md). ## Security Please report vulnerabilities via [SECURITY.md](./SECURITY.md). + +For usage questions and support boundaries, see [SUPPORT.md](./SUPPORT.md). diff --git a/GOVERNANCE.md b/GOVERNANCE.md new file mode 100644 index 0000000..02470e1 --- /dev/null +++ b/GOVERNANCE.md @@ -0,0 +1,71 @@ +# Governance + +LeRobot Studio is stewarded by IO-AI.TECH. This document defines project +decision-making and release authority; it does not grant legal authority on +behalf of the company. + +## Roles + +- **Contributors** propose issues, documentation, code, tests, and reviews. +- **Maintainers** triage issues, review and merge changes, enforce project + contracts, and manage community spaces. +- **Release maintainers** are a least-privilege subset of maintainers with npm, + GitHub release, container registry, and signing access. +- **Security maintainers** can access private security advisories and coordinate + disclosure. + +IO-AI.TECH appoints and removes maintainers based on sustained, constructive +participation, technical judgment, security practices, and availability. +Repository and team membership are the authoritative role records. + +## Decisions + +Routine changes use lazy consensus through reviewed pull requests. The author +does not approve their own change. Required checks and CODEOWNERS review must +pass before merge. + +Changes to the stable public API, dataset compatibility, privacy or telemetry +contract, security policy, licensing, governance, or release process require: + +1. a written proposal in an issue or pull request; +2. compatibility and migration analysis; and +3. approval from at least two maintainers, including a relevant CODEOWNER. + +When consensus cannot be reached, IO-AI.TECH’s designated project lead makes +the final decision and records the rationale. Security maintainers may act +privately and urgently to protect users. + +## Release authority + +Only release maintainers may publish npm packages, GitHub releases, tags, or +official container images. Production credentials must not be available to +untrusted pull-request jobs. Releases require: + +- a clean, reviewed commit on the protected release branch; +- passing required checks and acceptance gates; +- an updated changelog and version/tag agreement; +- provenance and artifact review where the release platform supports them; and +- approval by two maintainers, at least one of whom is a release maintainer. + +Published versions and tags are immutable. A faulty release is followed by a +new version; tags and registry artifacts are not silently replaced. Security +releases may use an expedited private review while retaining two-person +approval. + +The stable `1.0.0` release must satisfy +[Compatibility and release gates](./docs/compatibility.md), including official +LeRobot training-readiness validation for every advertised export path. + +## Compatibility and deprecation + +Major version 1 maintains backward compatibility for documented public APIs. +Deprecations follow [docs/deprecation.md](./docs/deprecation.md). Exact LeRobot +version support and the zero-telemetry npm contract cannot be weakened in a +minor or patch release. + +## Conduct, security, and conflicts + +Community participation follows [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md). +Vulnerabilities follow [SECURITY.md](./SECURITY.md). Maintainers disclose +material conflicts of interest and recuse themselves when impartial review is +not possible. diff --git a/NOTICE b/NOTICE index 4240a76..3092333 100644 --- a/NOTICE +++ b/NOTICE @@ -5,7 +5,8 @@ This repository depends on third-party packages. The exact dependency graph, versions, package registry locations, integrity hashes, and SPDX license identifiers are recorded in `package-lock.json`. -The following non-MIT license identifiers are present in the current lockfile: +Examples of non-MIT license identifiers present in the current lockfile +include: - `@fontsource-variable/geist` 5.3.0 — OFL-1.1 - `argparse` 2.0.1 — Python-2.0 @@ -15,7 +16,9 @@ The following non-MIT license identifiers are present in the current lockfile: - `spawndamnit` 3.0.1 — `SEE LICENSE IN LICENSE` - `tslib` 2.8.1 — 0BSD -Transitive copies of the packages above may also appear in the lockfile. This -notice is an index, not a replacement for the applicable third-party license -texts. Before distributing a built artifact, regenerate and review the -dependency inventory for the lockfile used to produce that artifact. +This list is representative, not exhaustive; development tools, optional +platform packages, and transitive dependencies may use additional licenses. +This notice is an index, not a replacement for the applicable third-party +license texts. Before distributing a built artifact, generate and review a +complete production-artifact dependency and asset inventory from the exact +lockfile and build output used for that release. diff --git a/README.md b/README.md index 4d50070..588daf4 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,29 @@ # LeRobot Studio -Browser-based visualizer for [LeRobot](https://github.com/huggingface/lerobot) datasets, by [IO-AI.TECH](https://io-ai.tech). +Browser-based viewer for [LeRobot](https://github.com/huggingface/lerobot) +datasets, by [IO-AI.TECH](https://io-ai.tech). Live demo: [https://lerobot.studio](https://lerobot.studio) -- Open local folders, local archives, or remote archives (CORS + Range required) -- Synced video / charts / raw feature inspection -- Export and light editing workflows -- Embeddable React package: `@ioai/lerobot-studio` +This repository is preparing the stable `1.0.0` contract. Viewing is the only +stable public library workflow: the package does not mutate source datasets and +does not expose an editor API. Export in the standalone application is not part +of the stable library API and remains a **1.0 release gate** until its output is +validated as directly consumable by official LeRobot training. + +- Open local folders, local archives, or remote archives +- Inspect synchronized video, charts, and raw features +- Embed the client-only React viewer through `@ioai/lerobot-studio` +- Process dataset content locally in the browser ## Open source -LeRobot Studio is developed in the open at -[ioai-tech/lerobot-studio](https://github.com/ioai-tech/lerobot-studio). +LeRobot Studio is preparing for public development at +`github.com/ioai-tech/lerobot-studio`. That public repository and its issue +tracker are part of the GitHub migration and may not be available yet. -- Report reproducible bugs or propose improvements through - [GitHub Issues](https://github.com/ioai-tech/lerobot-studio/issues). +- After publication, report reproducible bugs or propose improvements through + the repository’s GitHub Issues. - Read [CONTRIBUTING.md](./CONTRIBUTING.md) before submitting a pull request. - Follow the [Code of Conduct](./CODE_OF_CONDUCT.md) in all community spaces. - Report security vulnerabilities only through the private process in @@ -43,6 +51,9 @@ docker build -t lerobot-studio . docker run --rm -p 8080:80 lerobot-studio ``` +Then open [http://localhost:8080](http://localhost:8080). For production +hosting, see [Deployment](./docs/deployment.md). + ## Repository layout ``` @@ -57,6 +68,8 @@ docs/ Architecture and ops docs ## Library usage +After the `1.0.0` package is published: + ```bash npm install @ioai/lerobot-studio ``` @@ -66,29 +79,94 @@ import { LeRobotViewer } from '@ioai/lerobot-studio'; import '@ioai/lerobot-studio/style.css'; export function App() { - return ; + return ( +
+ +
+ ); } ``` +The host must give the viewer an explicit height; the component fills its +container and does not choose a page height. The `1.0.0` compatibility contract +is React and React DOM `^19.0.0`, ESM-only, browser-only, and CSR-only. Vite and +Next.js client-only consumer validation remains a 1.0 release gate. + +### Vite + +Import and render the component normally in client code. Do not externalize its +CSS, workers, or WASM assets from the browser build. + +### Next.js + +Load the viewer from a Client Component with SSR disabled: + +```tsx +'use client'; + +import dynamic from 'next/dynamic'; + +const Viewer = dynamic( + () => import('@ioai/lerobot-studio').then((module) => module.LeRobotViewer), + { ssr: false }, +); +``` + +LeRobot Studio does not support React Server Components, server rendering, +CommonJS, or execution in Node.js. + +## Compatibility contract + +- Stable line: `1.0.0`; minor and patch releases remain backward compatible + within major version 1, subject to the [deprecation policy](./docs/deprecation.md). +- Dataset formats planned for exact 1.0 support: LeRobot `v2.1` and `v3.0`. + Prefix-compatible variants such as `v2.0`, `v2.x`, or `v3.x` are not covered. +- Unknown versions must open only in warning-marked read-only mode when they can + be parsed safely, and export must remain disabled. This behavior is a 1.0 + release gate and is not yet guaranteed by the current implementation. +- Export for supported versions may be released only after official LeRobot + training-readiness validation. No best-effort export is considered supported. +- The npm library ships with zero telemetry. It must not send analytics, + diagnostics, dataset metadata, filenames, or usage events. + +See [Compatibility and release gates](./docs/compatibility.md) for browser, +dataset, network, and capability matrices. + ## Documentation +- [Quick Start](./docs/quick-start.md) +- [Stable API](./docs/api.md) +- [Data formats](./docs/data-formats.md) +- [CORS and HTTP Range](./docs/cors.md) +- [Browser support](./docs/browser.md) +- [Troubleshooting](./docs/troubleshooting.md) +- [Migration](./docs/migration.md) - [Architecture](./docs/architecture.md) +- [Compatibility and release gates](./docs/compatibility.md) - [Development](./docs/development.md) - [Deployment](./docs/deployment.md) +- [Privacy](./docs/privacy.md) +- [Deprecation policy](./docs/deprecation.md) - [GitHub migration checklist](./docs/github-migration.md) - [Contributing](./CONTRIBUTING.md) +- [Governance](./GOVERNANCE.md) +- [Support](./SUPPORT.md) - [Security](./SECURITY.md) - [Third-party notices](./NOTICE) ## Deploy -The public site at [https://lerobot.studio](https://lerobot.studio) is deployed by -Cloudflare Workers Builds connected to this GitHub repository (`main` branch). -See [Deployment](./docs/deployment.md). +The public site is [https://lerobot.studio](https://lerobot.studio). Connecting +the migrated GitHub `main` branch to Cloudflare Workers Builds is the planned +production path and must be verified during migration. See +[Deployment](./docs/deployment.md). ## Privacy -The open-source distribution does not ship third-party analytics (no Google Analytics / Clarity). +The npm library has a zero-telemetry contract. The application uses browser +storage for preferences, recent-source metadata, and—when the browser permits +it—local file handles. Dataset bytes are processed locally unless the user +opens a remote URL. See [Privacy](./docs/privacy.md). ## License diff --git a/SECURITY.md b/SECURITY.md index 9a2541c..d196a6c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,16 +2,21 @@ ## Supported versions -Security fixes are accepted for the latest released major version of -`@ioai/lerobot-studio`. +After `1.0.0` is released, security fixes are provided for the latest minor +release in major version 1. Older minors may receive a coordinated upgrade path +rather than a backport. Unreleased branches, development snapshots, forks, +third-party deployments, and modified distributions receive best-effort +support only. ## Reporting a vulnerability Please **do not** open a public issue for security vulnerabilities. -Submit a [private GitHub security advisory](https://github.com/ioai-tech/lerobot-studio/security/advisories/new). -This is the only supported reporting channel; this repository does not publish a -security email address. +After the public GitHub migration, submit a private GitHub security advisory +from the repository’s **Security → Advisories → Report a vulnerability** page. +Until that repository and advisory form are available, email +`io@io-ai.tech` with the subject “LeRobot Studio security report”. Do not send +secrets or sensitive dataset content in the first message. Include: @@ -20,5 +25,57 @@ Include: - Impact assessment - Any known mitigations -Maintainers will use the private advisory to coordinate follow-up and -disclosure. +Do not include sensitive dataset content unless maintainers request a minimal, +sanitized reproducer through the private advisory. + +## Response targets + +These are service targets, not guarantees: + +- acknowledgment within 3 business days; +- initial severity and scope assessment within 7 business days; +- a private status update at least every 7 business days while an accepted + report remains unresolved; +- critical-severity remediation target: 7 calendar days after confirmation; +- high-severity remediation target: 30 calendar days after confirmation; and +- moderate/low-severity remediation target: the next appropriate scheduled + release. + +Complex dependency, browser, or coordinated-disclosure cases may take longer. +Maintainers will explain material delays in the private advisory. + +## Disclosure and release + +Maintainers validate the report, assign severity based on practical impact, +prepare fixes and advisories privately, and coordinate disclosure with the +reporter. Please allow a reasonable remediation period before public +disclosure. Security releases may shorten the normal deprecation window. + +The project will publish an advisory when users need to act and will credit the +reporter unless anonymity is requested. Release authority follows +[GOVERNANCE.md](./GOVERNANCE.md). + +## Supply-chain security + +Pull requests are checked by CI, dependency review, and CodeQL. Scheduled +OpenSSF Scorecard analysis reports repository-level supply-chain findings. +Workflow permissions are read-only by default; jobs that upload security +results or publish releases receive only their required permissions. Actions +outside the release workflow are pinned to full commit SHAs and tracked by +Dependabot. + +Repository administrators must separately enable and verify the GitHub security +features and branch rules described in +[docs/github-migration.md](./docs/github-migration.md). Checked-in workflows do +not prove that those settings are active. Report a suspected compromised +dependency, action, package, release artifact, or maintainer credential through +the private vulnerability channel above. + +## Scope + +In scope are vulnerabilities in this repository’s distributed web application +and npm package, including unsafe parsing of untrusted datasets, cross-origin or +storage boundary violations, unintended data disclosure, dependency or build +compromise, and bypass of read-only/export restrictions. Service availability, +remote dataset hosts, host-application code, unsupported browsers, and social +engineering are normally out of scope unless they demonstrate a project defect. diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..db17fd1 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,51 @@ +# Support + +LeRobot Studio is community-supported open-source software. No response time, +availability, compatibility, or remediation commitment is created by this +document. Security reports use the separate targets in +[SECURITY.md](./SECURITY.md). + +## Where to ask + +- Reproducible product defects: after the public migration, use GitHub Issues + in `ioai-tech/lerobot-studio` +- Feature and compatibility proposals: after the public migration, use GitHub + Issues with the use case, dataset version, and migration impact +- Vulnerabilities: private reporting only through + [SECURITY.md](./SECURITY.md) +- Contributions: [CONTRIBUTING.md](./CONTRIBUTING.md) + +The public issue tracker is not considered available until the GitHub migration +checklist is complete. Before opening an issue, search existing reports and +test the latest supported release. Include the LeRobot `codebase_version`, +browser and OS versions, input type, minimal reproduction, console errors, and +whether the issue occurs in the standalone app or npm package. Remove +credentials, signed URLs, personal data, and confidential dataset content. + +## Supported boundary + +For stable 1.0, support is limited to the documented viewer API, exact LeRobot +`v2.1` and `v3.0`, supported browser/input combinations, and documented +deployment behavior. See [Compatibility](./docs/compatibility.md). + +The following are outside community support unless a project defect can be +reproduced within that boundary: + +- custom or unknown LeRobot formats and manually repaired datasets; +- SSR, React Server Components, CommonJS, Node.js execution, or React versions + outside the documented range; +- host-application code, authentication, proxies, CORS configuration, remote + storage availability, and browser codec availability; +- forks, private patches, unsupported releases, and third-party deployments; +- dataset recovery, annotation, training, model quality, or robotics advice; + and +- guaranteed export before official training-readiness validation is complete. + +Unknown dataset versions are not supported formats. The 1.0 target is safe, +warning-marked read-only inspection when possible, with export prohibited. + +## Commercial support + +This repository does not promise private or commercial support. Any separate +agreement with IO-AI.TECH is governed by that agreement, not by project issues +or this document. diff --git a/api-extractor.json b/api-extractor.json new file mode 100644 index 0000000..f76735e --- /dev/null +++ b/api-extractor.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "/temp/types/react/index.d.ts", + "compiler": { + "tsconfigFilePath": "/tsconfig.lib.json", + "skipLibCheck": true + }, + "apiReport": { + "enabled": true, + "reportFolder": "/etc", + "reportTempFolder": "/temp" + }, + "docModel": { + "enabled": false + }, + "dtsRollup": { + "enabled": true, + "untrimmedFilePath": "/dist-lib/lerobot.d.ts" + }, + "tsdocMetadata": { + "enabled": false + }, + "messages": { + "extractorMessageReporting": { + "ae-forgotten-export": { + "logLevel": "error" + }, + "ae-missing-release-tag": { + "logLevel": "none" + } + } + } +} diff --git a/components.json b/components.json index d773367..71297e7 100644 --- a/components.json +++ b/components.json @@ -13,11 +13,11 @@ "iconLibrary": "lucide", "rtl": false, "aliases": { - "components": "src/components", - "utils": "src/lib/utils", - "ui": "src/ui/components", - "lib": "src/lib", - "hooks": "src/hooks" + "components": "src/ui/components", + "utils": "src/ui/lib/utils", + "ui": "src/ui/components/ui", + "lib": "src/ui/lib", + "hooks": "src/react/hooks" }, "menuColor": "default", "menuAccent": "subtle", diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts new file mode 100644 index 0000000..e937245 --- /dev/null +++ b/docs/.vitepress/config.ts @@ -0,0 +1,70 @@ +import { defineConfig } from 'vitepress'; + +export default defineConfig({ + title: 'LeRobot Studio', + description: 'Browser-based viewer and React library for LeRobot datasets', + lang: 'en-US', + base: process.env.DOCS_BASE ?? '/', + cleanUrls: true, + lastUpdated: true, + markdown: { + lineNumbers: true, + }, + themeConfig: { + nav: [ + { text: 'Guide', link: '/quick-start' }, + { text: 'API', link: '/api' }, + { text: 'Data formats', link: '/data-formats' }, + { text: 'Operations', link: '/deployment' }, + { text: 'Live demo', link: 'https://lerobot.studio' }, + ], + sidebar: [ + { + text: 'Start', + items: [ + { text: 'Introduction', link: '/' }, + { text: 'Quick Start', link: '/quick-start' }, + { text: 'API', link: '/api' }, + ], + }, + { + text: 'Compatibility', + items: [ + { text: 'Data formats', link: '/data-formats' }, + { text: 'CORS and Range', link: '/cors' }, + { text: 'Browser support', link: '/browser' }, + { text: 'Release gates', link: '/compatibility' }, + ], + }, + { + text: 'Guides', + items: [ + { text: 'Privacy', link: '/privacy' }, + { text: 'Troubleshooting', link: '/troubleshooting' }, + { text: 'Migration', link: '/migration' }, + { text: 'Deprecation policy', link: '/deprecation' }, + ], + }, + { + text: 'Project', + collapsed: true, + items: [ + { text: 'Architecture', link: '/architecture' }, + { text: 'Development', link: '/development' }, + { text: 'Deployment', link: '/deployment' }, + { text: 'GitHub migration', link: '/github-migration' }, + ], + }, + ], + search: { + provider: 'local', + }, + outline: { + level: [2, 3], + }, + footer: { + message: 'Released under the MIT License.', + copyright: 'Copyright © 2026 IO-AI.TECH', + }, + }, +}); diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..9482287 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,54 @@ +# Stable API + +The generated API report is the source of truth for the publishable surface. +The stable `1.0` contract is **viewer-only**: it contains the viewer, browser +data-source factories, and their public types. Product internals, source +mutation, editing, and export services are not public API. + +::: warning Publication status +The package is preparing for `1.0.0` and has not been published. The API report +describes the intended stable surface, but publication still depends on the +[1.0 release gates](./compatibility.md). +::: + +## Primary entry point + +```tsx +import { LeRobotViewer } from '@ioai/lerobot-studio'; +import '@ioai/lerobot-studio/style.css'; + + { + console.error(error.code, error.message); + }} +/>; +``` + +`dataSource` accepts either a remote archive URL or a custom `DataSource`. +Use `createArchiveDataSourceFromFile` for a local ZIP, TAR, or TAR.GZ archive, +and `createArchiveDataSourceFromUrl` when you need a reusable remote source. + +The optional `onExport` prop is only a host-owned UI callback. It does not +export data and does not make export part of the stable npm API. + +## Custom data sources + +A custom `DataSource` supplies browser-readable bytes and text by path. It may +also provide path listing and object URL lifecycle methods. Implementations +must treat dataset content as untrusted input and should release object URLs +and other resources from `clear()`. + +Progress callbacks use the `download`, `index`, `gunzip`, and `read` loading +phases. Fatal viewer errors are classified as invalid source, unavailable +remote source, unsupported archive, or dataset load failure. + +## Generated API report + +The following section is included directly from +`etc/lerobot-studio.api.md`. Update public declarations through the library +source and `npm run api:report`; do not hand-edit the report or duplicate it +here. + + diff --git a/docs/architecture.md b/docs/architecture.md index e451402..df6dc98 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,7 @@ # Architecture -LeRobot Studio is a single npm package and SPA repository. The directory boundaries are architectural, not separately published packages. +LeRobot Studio is a single npm package and client-rendered SPA repository. The +directory boundaries are architectural, not separately published packages. ``` src/core Pure TypeScript domain logic @@ -29,4 +30,32 @@ src/web → src/react → src/platform → src/core ## Public API -The publishable surface is `src/react/index.ts`. Styles are exposed as `@ioai/lerobot-studio/style.css`. +The publishable surface is `src/react/index.ts`. Styles are exposed as +`@ioai/lerobot-studio/style.css`. + +The stable 1.0 package contract is viewer-only, ESM-only, browser-only, and +CSR-only with React and React DOM `^19.0.0`. “Viewer-only” means that dataset +viewing is the only stable library workflow; source mutation, editing, export +services, and standalone application controls are not stable public APIs. Any +export that exceeds that boundary must remain internal or be explicitly +classified before 1.0. + +Public APIs remain backward compatible within major version 1 according to the +[deprecation policy](./deprecation.md). + +## Trust boundaries + +- Dataset parsing, validation, media handling, and export operate on untrusted + input and must fail without modifying source data. +- Unknown dataset versions must not silently select a version adapter. The 1.0 + behavior is warning-marked read-only inspection when safe, with export + disabled. +- Remote sources cross an external network boundary; CORS, Range, + authentication, availability, and remote logging are controlled by the host + and source server. +- Browser storage is optional and failure-tolerant. See [Privacy](./privacy.md). +- The npm library has a zero-telemetry contract. + +These trust-boundary guarantees include 1.0 release gates that are tracked in +[Compatibility](./compatibility.md); they are not all enforced by the current +pre-release implementation. diff --git a/docs/browser.md b/docs/browser.md new file mode 100644 index 0000000..3ca6a00 --- /dev/null +++ b/docs/browser.md @@ -0,0 +1,38 @@ +# Browser support + +LeRobot Studio targets modern browsers, but feature availability depends on +browser APIs, media codecs, dataset encoding, and deployment policy. “Modern” +does not guarantee that every video can be decoded or encoded. + +## Validation scope + +CI runs the full browser suite in Chromium and a compatibility smoke suite in +Firefox and WebKit. The smoke suite covers viewer composition, localized error +states, keyboard and accessibility primitives, unknown-version read-only +behavior, and remote or custom `DataSource` contracts. + +| Capability | Chromium | Firefox | WebKit | +| ---------------------------- | ------------------------- | --------------------- | --------------------- | +| Viewer and error states | CI smoke | CI smoke | CI smoke | +| Remote/custom data source | CI smoke | CI smoke | CI smoke | +| Local archive viewing | Planned validation | Planned validation | Planned validation | +| Restorable directory handles | Capability detected | Archive-only fallback | Archive-only fallback | +| Directory export | Chromium only | Unavailable | Unavailable | +| Video encoding/export | Full suite with WebCodecs | Capability detected | Capability detected | +| Video playback | Codec dependent | Codec dependent | Codec dependent | + +This matrix separates tested scope from intended support. Release evidence must +record actual browser versions, and all 1.0 claims remain subject to the +[release gates](./compatibility.md). + +## Client-only requirement + +The package accesses browser facilities and must be rendered only on the +client. It does not support SSR, hydration of server-rendered viewer markup, +React Server Components, Node.js, or CommonJS. + +## Graceful degradation + +Missing File System Access or WebCodecs capabilities should remove or disable +the affected operation rather than break inspection. Unsupported media should +produce a clear error while non-media inspection remains available where safe. diff --git a/docs/compatibility.md b/docs/compatibility.md new file mode 100644 index 0000000..5dafa08 --- /dev/null +++ b/docs/compatibility.md @@ -0,0 +1,114 @@ +# Compatibility and release gates + +This document separates the stable `1.0.0` contract from behavior that still +requires implementation or verification. A row marked **1.0 gate** must not be +presented as supported until its automated and manual acceptance checks pass. + +## Runtime contract + +- React and React DOM: `^19.0.0` +- Module format: ESM only +- Rendering: client-side rendering only +- Runtime: modern browsers only; Node.js, CommonJS, React Server Components, + server-side rendering, and hydration of server-rendered viewer markup are out + of scope +- Compatibility: public APIs remain backward compatible throughout major + version 1, subject to the [deprecation policy](./deprecation.md) +- Telemetry: zero telemetry in the npm library + +The package metadata declares this React peer range. Validating Vite and Next.js +client-only consumers remains a 1.0 release gate. + +## Dataset support + +| Dataset `codebase_version` | Open and inspect | Export | 1.0 status | +| -------------------------- | ---------------------------------------------- | ----------------------------- | ---------- | +| Exactly `v2.1` | Planned support | Planned only after validation | 1.0 gate | +| Exactly `v3.0` | Planned support | Planned only after validation | 1.0 gate | +| Missing or any other value | Warning-marked read-only when safely parseable | Prohibited | 1.0 gate | + +“Exact” applies to the declared dataset version, not a prefix family. In +particular, `v2.0`, future `v2.x`, future `v3.x`, and malformed or missing +versions are unknown. Unknown data must never silently select a compatible +adapter. If safe parsing is impossible, loading must fail without modifying the +source. + +Supported export means that the complete exported dataset can be consumed by +the corresponding official LeRobot release for training without manual repair. +The validation fixture set must cover metadata, tasks, episodes, Parquet data, +timestamps, images/video, and cross-version export where offered. Until those +checks pass, export is experimental and must not be advertised as supported. + +For v3 data, Studio rolls Parquet files by the configured data-file size and +uses `chunks_size` as the maximum number of files per chunk. Browser export +currently keeps each episode video in a separate MP4 and rolls video chunks by +file count. Official LeRobot may losslessly concatenate several episode videos +into one size-bounded MP4; Studio does not claim byte-for-byte sharding parity, +but its per-episode layout and metadata references are validated by the official +v0.6.1 reader. + +## Input and network support + +| Input | Contract | +| --------------- | -------------------------------------------------------------------------------------------- | +| Local archive | Browser-local read; archive types shown by the UI must be tested before 1.0 | +| Local directory | Requires the File System Access API; fallback selection may not preserve a restorable handle | +| Remote archive | Requires cross-origin `GET`; byte-range access is required for scalable large-archive use | +| Sample URL | Same origin, CORS, and Range rules as any remote archive | + +For remote archives, the origin should: + +- answer the preflight or request with an appropriate + `Access-Control-Allow-Origin`; +- allow `GET` and the `Range` request header when preflighted; +- expose `Content-Range`, `Accept-Ranges`, and `Content-Length` when the client + needs to inspect them; and +- respond to a valid byte request with `206 Partial Content` and a valid + `Content-Range`. + +A server that ignores Range may force a full download and is not considered a +supported large-dataset configuration. Authentication, private object-store +credentials, signed-URL generation, and proxying are host-application +responsibilities. + +## Browser capability matrix + +The automated matrix uses Playwright Chromium, Firefox, and WebKit. “Smoke” +means Viewer composition, localized error states, keyboard/accessibility +primitives, unknown-version read-only behavior, and remote/custom `DataSource` +contracts. It does not imply codec support for every dataset. + +| Capability | Chromium (Chrome/Edge) | Firefox | WebKit (Safari engine) | +| --------------------------------------- | ---------------------------------------------- | ------------------------------------------ | ------------------------------------------ | +| Viewer/error/accessibility/read-only | CI smoke | CI smoke | CI smoke | +| Remote or custom `DataSource` | CI smoke | CI smoke | CI smoke | +| Local archive viewing | Planned validation | Planned validation | Planned validation | +| Directory picker and restorable handles | File System Access capability detected | Explicit ZIP-only fallback when absent | Explicit ZIP-only fallback when absent | +| ZIP download | Capability detected; export suite exercised | Capability detected in smoke | Capability detected in smoke | +| Directory export | Chromium-only; requires File System Access | Not run; directory option must stay absent | Not run; directory option must stay absent | +| Video encoding/export | Full browser export suite, including WebCodecs | Not run; WebCodecs capability is detected | Not run; WebCodecs capability is detected | +| Video decode/playback | Dataset/codec dependent | Dataset/codec dependent | Dataset/codec dependent | + +CI installs all three engines. Chromium retains the complete browser suite, +including export and encoding scenarios. Firefox and WebKit run only the smoke +scope above; a missing browser capability is tested as an explicit degradation +contract rather than converted into a skipped test. + +The pinned official compatibility gate uses LeRobot `v0.6.1` commit +`7e241bd630a3719a56157a497ce5d08f244784f1`. Nightly also checks the current +upstream `main` as a non-blocking warning job. Exact browser versions must be +recorded in release evidence. A browser being “modern” does not guarantee codec +support; unsupported media must produce a clear error while non-media +inspection remains available where safe. + +## 1.0 acceptance gates + +- Enforce exact `v2.1` and `v3.0` detection. +- Provide a visible warning and immutable mode for unknown versions; disable + every export entry point and service call in that mode. +- Validate supported exports with official LeRobot training workflows. +- Keep React peer dependencies at `^19.0.0` and test Vite and Next.js + client-only consumers. +- Run the browser matrix and retain versioned evidence. +- Confirm the npm artifact has no telemetry or host-application side effects + beyond documented browser storage. diff --git a/docs/cors.md b/docs/cors.md new file mode 100644 index 0000000..1e84f95 --- /dev/null +++ b/docs/cors.md @@ -0,0 +1,50 @@ +# CORS and HTTP Range + +The browser fetches a remote archive from the URL supplied by the host or user. +That archive server controls CORS, byte-range behavior, authentication, and +request logging. + +## Required response behavior + +For a public, credential-free archive, a typical configuration includes: + +```http +Access-Control-Allow-Origin: * +Accept-Ranges: bytes +Access-Control-Expose-Headers: Accept-Ranges, Content-Length, Content-Range +``` + +When the viewer requests `Range: bytes=0-65535`, the server should return: + +```http +HTTP/1.1 206 Partial Content +Content-Range: bytes 0-65535/123456789 +Content-Length: 65536 +``` + +If a preflight occurs, allow: + +```http +Access-Control-Allow-Methods: GET, OPTIONS +Access-Control-Allow-Headers: Range +``` + +Use the viewer origin instead of `*` for credentialed requests. Never combine +`Access-Control-Allow-Origin: *` with credentials. + +## Why Range matters + +A server that ignores `Range` may force the browser to download the complete +archive before inspection. That behavior is not a supported large-dataset +configuration. Redirects, CDNs, and signed URLs must preserve both the CORS +headers and `206 Partial Content` behavior. + +## Host responsibilities + +The embedding application owns private credentials, signed-URL generation, +proxies, and authorization. The stable viewer does not accept or manage server +credentials. Prefer short-lived signed URLs over exposing object-store secrets +to browser code. + +See [Troubleshooting](./troubleshooting.md) for checks to run when a remote +archive does not open. diff --git a/docs/data-formats.md b/docs/data-formats.md new file mode 100644 index 0000000..af60a7f --- /dev/null +++ b/docs/data-formats.md @@ -0,0 +1,48 @@ +# Data formats + +Format support is version-specific. A matching major prefix is not enough: +`v2.0`, future `v2.x`, future `v3.x`, malformed values, and missing +`codebase_version` are unknown formats. + +## Intended 1.0 matrix + +| Declared version | Open and inspect | Export | Status | +| -------------------------- | --------------------------------------------------- | ------------------------------ | -------- | +| Exactly `v2.1` | Planned support | Only after official validation | 1.0 gate | +| Exactly `v3.0` | Planned support | Only after official validation | 1.0 gate | +| Missing or any other value | Warning-marked read-only mode when safely parseable | Prohibited | 1.0 gate | + +These entries describe the release contract, not a claim that the current +pre-release implementation has passed every gate. See +[Compatibility and release gates](./compatibility.md) for the acceptance +criteria. + +## Inputs + +- Local archives: ZIP, TAR, or TAR.GZ through the public archive factory. +- Local directories: require File System Access API support; other browsers + may offer an archive-only fallback. +- Remote archives: require cross-origin `GET`. Large archives also require + working byte ranges. +- Custom sources: implement the stable [`DataSource`](./api.md) contract. + +Archive contents and paths are untrusted input. Loading must fail safely and +must not modify the source dataset. + +## Parquet and video sharding + +For v3 export, Studio rolls Parquet files by the configured data-file size and +uses `chunks_size` as the maximum file count in each chunk. + +Video layout differs from official LeRobot: + +- Studio currently writes one MP4 per episode and rolls video chunks by file + count. +- Official LeRobot may losslessly concatenate multiple episode videos into one + size-bounded MP4. + +Studio therefore does **not** claim byte-for-byte video sharding parity. +Per-episode layout and metadata references have been read successfully by the +pinned official LeRobot `v0.6.1` reader, but supported export still requires +complete official training-readiness validation. Until that gate passes, +export remains experimental and outside the stable npm API. diff --git a/docs/deployment.md b/docs/deployment.md index dea89a1..91b8755 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,14 +1,25 @@ # Deployment -## Production site (Cloudflare Workers) +## Production target (Cloudflare Workers) Production is served at **https://lerobot.studio**. -The GitHub repository [`ioai-tech/lerobot-studio`](https://github.com/ioai-tech/lerobot-studio) -is connected directly to a Cloudflare Worker. Merges to `main` trigger a Cloudflare -build and deploy — no GitHub Actions secrets are required for this path. +The planned post-migration setup connects `ioai-tech/lerobot-studio` directly to +a Cloudflare Worker so merges to `main` trigger a Cloudflare build and deploy +without GitHub Actions deployment secrets. The repository connection and first +deployment must be verified during the +[GitHub migration](./github-migration.md); this document does not assert that +the public repository is already available. -Worker configuration lives in [`wrangler.jsonc`](../wrangler.jsonc): +The Cloudflare repository integration is a separate trust boundary from GitHub +Actions. Grant it access only to this repository, restrict production deploys +to the protected `main` branch, and review its build logs and dependency update +behavior. Fork pull-request workflows must not receive Cloudflare credentials; +the planned integration does not require storing a Cloudflare API token as a +GitHub Actions secret. + +Worker configuration lives in +[`wrangler.jsonc`](https://github.com/ioai-tech/lerobot-studio/blob/main/wrangler.jsonc): - Worker name: `lerobot-studio` - Assets directory: `dist` @@ -44,7 +55,7 @@ Optional build args: - `SITE_URL` — canonical site URL for SEO assets (production default: `https://lerobot.studio`) - `VITE_SAMPLES_BASE_URL` — remote sample manifest base URL -Published images (after open release): +Planned published images (after a successful public release): - `ghcr.io/ioai-tech/lerobot-studio:latest` - `ghcr.io/ioai-tech/lerobot-studio:` @@ -58,13 +69,59 @@ SITE_URL=https://lerobot.studio npm run build Serve `dist` with any static file server. Configure SPA fallback to `index.html`. +## Documentation site (GitHub Pages) + +The documentation is a separate VitePress build: + +```bash +npm run docs:build +``` + +`.github/workflows/docs-pages.yml` builds and deploys +`docs/.vitepress/dist` after documentation changes reach `main`. Its default +`DOCS_BASE` is `//`, so it works at the GitHub project Pages +URL without assuming that a custom documentation domain exists. + +Repository administrators must complete these steps after the public +repository exists: + +1. Open **Settings → Pages** and set **Source** to **GitHub Actions**. +2. Run the **Documentation Pages** workflow once and verify the generated + `https://ioai-tech.github.io/lerobot-studio/` URL. +3. Add the workflow’s `build` and `deploy` jobs to the protected-branch policy + if documentation deployment is required for merges. +4. Only after DNS ownership is ready, configure a custom domain in GitHub + Pages, add the documented DNS records, enable HTTPS, and verify the domain. +5. For a root custom domain such as `docs.lerobot.studio`, change the workflow + `DOCS_BASE` to `/`. Do not add a `CNAME` file or advertise that hostname + until GitHub Pages and DNS both confirm it. + +The workflow requires `pages: write` and `id-token: write` only in the deploy +job. Pull requests build the same site through the read-only CI job but do not +deploy it. + ## Sample datasets and CORS -Remote archives require: +Remote archives must allow cross-origin `GET`. Scalable access to large +archives also requires byte ranges. A typical object response includes: + +- `Access-Control-Allow-Origin: https://your-viewer.example` (or `*` for a + genuinely public, credential-free object); +- `Accept-Ranges: bytes`; +- `Content-Length`; +- a `206 Partial Content` response with a valid `Content-Range` when the client + sends `Range: bytes=...`; and +- `Access-Control-Expose-Headers: Accept-Ranges, Content-Length, Content-Range` + when those headers must be read by browser code. -- CORS allowing `GET` from your site origin -- HTTP `Range` support for large archives +If a preflight occurs, allow `GET`, `OPTIONS`, and the `Range` request header. +Do not use wildcard origins with credentialed requests. A server that ignores +Range may trigger a full download and is not a supported large-dataset setup. +See [Compatibility](./compatibility.md) for the complete network boundary. ## Privacy -The open-source web shell does **not** include Google Analytics or Microsoft Clarity. +The npm library has a zero-telemetry contract. The open-source web shell does +not include Google Analytics or Microsoft Clarity. Deployers are responsible +for disclosing logs, analytics, proxies, sample endpoints, authentication, and +retention that they add. See [Privacy](./privacy.md). diff --git a/docs/deprecation.md b/docs/deprecation.md new file mode 100644 index 0000000..fd60f73 --- /dev/null +++ b/docs/deprecation.md @@ -0,0 +1,28 @@ +# Deprecation policy + +LeRobot Studio follows Semantic Versioning for the public +`@ioai/lerobot-studio` API. + +## Major version 1 + +- Minor and patch releases must remain backward compatible with documented + public APIs and behavior. +- Deprecations must be documented in the changelog and migration guidance. +- A deprecated public API remains available for at least one minor release and + at least 90 days, whichever is longer, before removal in the next major + release. +- Security, privacy, legal, or data-integrity issues may require faster removal. + The release notes must explain the exception and provide mitigation or + migration guidance where practical. +- Undocumented internals, `src/**` paths, generated files, and standalone web + application UI details are not public API. + +## Dataset and browser support + +Removing a documented dataset version, browser class, input method, or export +target is a breaking change. Adding support for a new exact LeRobot version is +not automatic: it requires explicit validation and documentation. + +Warnings must identify the deprecated capability, the replacement, and the +earliest release in which removal may occur. Silent behavior changes are not an +acceptable deprecation mechanism. diff --git a/docs/development.md b/docs/development.md index 7c34908..3261029 100644 --- a/docs/development.md +++ b/docs/development.md @@ -17,11 +17,47 @@ Node 24 is required (`.nvmrc`). This repository is a single npm package with two | `npm run dev` | Start the SPA | | `npm run build` | Build the SPA to `dist/` | | `npm run build:lib` | Build the published library to `dist-lib/` | +| `npm run docs:dev` | Start the VitePress documentation site | +| `npm run docs:build` | Build documentation and check links | +| `npm run check:bundle-size` | Enforce npm and Web bundle size budgets | | `npm run typecheck` | Type-check application and library sources | | `npm run test:unit` | Run Node-based Vitest tests | | `npm run test:browser` | Run Playwright browser tests | | `npm run fixtures:generate` | Recreate synthetic LeRobot fixtures | +## Bundle size gate + +Build both targets before running the size gate: + +```bash +npm run build:lib +npm run build +npm run check:bundle-size +``` + +The gate measures raw and gzip sizes for the stable library entry, library and +initial Web CSS, Web entry/modulepreload chunks, lazy DataLoader and Dockview +chunks, Worker/WASM artifacts, and the packed npm tarball. It also fails when +required artifacts are missing, an unexpected JavaScript chunk exceeds the +per-chunk ceiling, or the complete npm tarball exceeds its packed or unpacked +budget. Hashed filenames are discovered from build output and `dist/index.html` +rather than being fixed in the script. + +The hard limits are approximately 5% above the measured baseline. The roughly +26 MB library Worker chunk is a known baseline that still needs optimization, +not an ideal or optimized target. Passing this gate only means that bundle size +has not materially regressed. + +For a local negative test, lower every budget without changing the checked-in +limits: + +```bash +BUNDLE_SIZE_BUDGET_SCALE=0.01 npm run check:bundle-size +``` + +The scale override can only tighten budgets (`0 < scale <= 1`); it cannot relax +the CI gate. + ## Package validation ```bash @@ -29,6 +65,27 @@ npm run build:lib npm run verify:npm-consumer ``` +The 1.0 release requires consumer fixtures for Vite and Next.js client-only +usage, React/React DOM `^19.0.0`, ESM import, CSS loading, workers, and WASM. +Server rendering, React Server Components, CommonJS, and Node.js execution are +outside the package contract. + +## Compatibility work + +Use [Compatibility and release gates](./compatibility.md) as the acceptance +source for version and browser work. In particular: + +- match dataset versions exactly (`v2.1` and `v3.0`), never by major prefix; +- keep unknown versions warning-marked, read-only, and unable to reach any + export UI or service; +- treat official LeRobot training-readiness checks as required export tests; + and +- record actual browser versions and results rather than inferring support from + API detection. + +Changes to public behavior must follow the +[deprecation policy](./deprecation.md). + ## UI components UI primitives live in `src/ui` and are managed from the repository root with the shadcn CLI. Keep generated components in `src/ui/components` and use local `@/ui` or relative imports. Do not reintroduce `@radix-ui/*`; prefer Base UI composition via `render`. diff --git a/docs/github-migration.md b/docs/github-migration.md index 225a39a..ed1075b 100644 --- a/docs/github-migration.md +++ b/docs/github-migration.md @@ -10,7 +10,7 @@ This branch is prepared to be copied into a new public GitHub repository ```bash rsync -a --exclude '.git' --exclude 'node_modules' --exclude 'dist' \ - --exclude 'dist' --exclude 'dist-lib' \ + --exclude 'dist-lib' \ ./ /path/to/lerobot-studio-public/ ``` @@ -25,13 +25,61 @@ git remote add origin git@github.com:ioai-tech/lerobot-studio.git git push -u origin main ``` -4. Configure GitHub: - - Protect `main` - - Add `NPM_TOKEN` repository secret for releases +4. Configure GitHub (these are manual administrator actions; the files in this + repository do not enable repository or organization settings): + - In **Settings → Actions → General**, set the default workflow token to + read-only and leave “Allow GitHub Actions to create and approve pull + requests” disabled. + - In the fork pull-request settings, do not send write tokens or Actions + secrets to workflows from forks. Require approval for first-time or + outside contributors. The checked-in PR workflows use `pull_request` + rather than `pull_request_target`, request read-only contents by default, + and do not consume repository secrets. + - Create a branch ruleset for `main`: require a pull request, at least one + approval, CODEOWNERS review, conversation resolution, and successful + required checks; block force pushes and deletion. Select the CI jobs + (`quality`, `browser-tests`, and `official-compat`), Dependency Review, and + CodeQL checks after they have run once so GitHub exposes their exact check + names. + - Create the `@ioai-tech/maintainers` team referenced by `CODEOWNERS`, or + replace that placeholder with an existing team that has write access + - In **Settings → Code security**, enable the dependency graph, Dependabot + alerts and security updates, secret scanning and push protection, and + private vulnerability reporting. Use the checked-in CodeQL advanced-setup + workflow; do not also enable CodeQL default setup. + - Verify the Dependency Review, CodeQL, and OpenSSF Scorecard workflows + succeed. Scorecard publication sends public repository assessment data to + `api.scorecard.dev`; disable `publish_results` in + `.github/workflows/scorecard.yml` if that publication is not desired. + - Keep GitHub Actions restricted to GitHub-authored and explicitly approved + third-party actions where organization policy supports it. All non-release + workflows are pinned to full commit SHAs and Dependabot is configured to + propose GitHub Actions updates. + - Add a one-time `NPM_BOOTSTRAP_TOKEN` repository secret for the initial + `v1.0.0` publication. npm cannot configure a trusted publisher before the + scoped package exists. + - Immediately after `v1.0.0` exists, configure npm Trusted Publishing for + `ioai-tech/lerobot-studio` and `.github/workflows/release.yml`, then delete + the bootstrap secret. Later releases use GitHub OIDC and no npm token. - Enable GitHub Packages / GHCR permissions for Actions + - Add a tag ruleset for `v*` so only release maintainers can create or update + release tags. - Verify Actions workflows run on the first PR + - In **Settings → Pages**, choose **GitHub Actions** as the source, run the + Documentation Pages workflow, and verify the project Pages URL. A custom + `docs.lerobot.studio` domain and its DNS records are optional later + administrator work; they are not preconfigured by this repository. - Connect the repo to Cloudflare Workers Builds for `main` → https://lerobot.studio (no Cloudflare API tokens needed in GitHub Secrets; use Cloudflare’s Git integration) - Confirm Worker `lerobot-studio` and custom domain `lerobot.studio` remain bound -5. Cut the first release tag (`v1.0.0` or `v0.1.0`) to exercise the release workflow. +5. Complete every gate in [Compatibility](./compatibility.md), including exact + dataset-version handling and official training-readiness validation. +6. Run the `Release Dry Run` workflow. It executes the reusable CI, + `npm publish --dry-run`, CycloneDX SBOM generation, and a no-push container + build without requiring release credentials. +7. Follow + [Governance](https://github.com/ioai-tech/lerobot-studio/blob/main/GOVERNANCE.md) + to approve and cut the stable `v1.0.0` release. Published npm versions, + container tags, and GitHub release artifacts are immutable; the release + attaches the SBOM and provenance. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..259a5a7 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,36 @@ +--- +layout: home + +hero: + name: LeRobot Studio + text: Inspect LeRobot datasets in the browser + tagline: A client-only React viewer for synchronized video, charts, and raw features. + actions: + - theme: brand + text: Quick Start + link: /quick-start + - theme: alt + text: Stable API + link: /api + - theme: alt + text: Live demo + link: https://lerobot.studio + +features: + - title: Viewer-only contract + details: Embed the stable client-side viewer without exposing dataset mutation or editor APIs. + - title: Browser-local processing + details: Local dataset content stays on the device. The npm library has a zero-telemetry contract. + - title: Explicit compatibility + details: Exact dataset versions, browser capabilities, and release gates are documented separately. +--- + +## Current release status + +LeRobot Studio is preparing the stable `1.0.0` contract. The package is not yet +published. Viewing is the only stable public library workflow; source mutation, +editing, and export services are not part of the stable API. + +Start with the [Quick Start](./quick-start.md), then review the +[compatibility and release gates](./compatibility.md) before treating a dataset +format or browser capability as supported. diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..d3ff62d --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,47 @@ +# Migration + +## Before 1.0 + +The package has not been published, so there is no supported npm upgrade path +yet. Do not depend on `src/**`, generated declarations, standalone application +components, export services, or other implementation details. + +Prepare an embedding application by depending only on the +[viewer-only API report](./api.md): + +- `LeRobotViewer` +- `createArchiveDataSourceFromFile` +- `createArchiveDataSourceFromUrl` +- the public viewer and `DataSource` types +- `@ioai/lerobot-studio/style.css` + +An `onExport` handler is a host-owned button callback, not an exported dataset +service. + +## Moving from application internals + +1. Replace deep imports with the package root. +2. Move export, authentication, signed URL, and proxy behavior into the host + application. +3. Supply a remote URL or implement the stable `DataSource` interface. +4. Render only on the client and give the viewer container an explicit height. +5. Treat all non-exact dataset versions as unknown instead of choosing an + adapter by major prefix. + +## After 1.0 + +Major version 1 follows Semantic Versioning. Minor and patch releases retain +backward compatibility for documented public APIs. A deprecated API remains +available for at least one minor release and 90 days, except where security, +privacy, legal, or data-integrity issues require faster action. + +Before upgrading: + +1. read the project changelog and release notes; +2. review documented deprecations and exact dataset-version support; +3. rebuild a real Vite or client-only Next.js consumer; and +4. test target browsers and representative dataset codecs. + +See the [deprecation policy](./deprecation.md) for the complete contract. The +repository publication procedure is documented separately in +[GitHub migration](./github-migration.md). diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 0000000..9421c97 --- /dev/null +++ b/docs/privacy.md @@ -0,0 +1,55 @@ +# Privacy + +## Data processing + +LeRobot Studio is designed to process dataset content in the browser. Opening a +local file or directory does not intentionally upload its contents. Opening a +remote archive necessarily sends requests to the user-provided remote host, and +that host may log normal request data such as IP address, user agent, referrer, +and requested URL. + +The `@ioai/lerobot-studio` npm library has a zero-telemetry contract. It must not +send analytics, diagnostics, dataset metadata, filenames, usage events, or +identifiers to IO-AI.TECH or third parties. Host applications remain +responsible for disclosing any telemetry or network behavior they add around +the library. + +## Browser storage + +The current application may store: + +- UI preferences such as theme and language in `localStorage`; +- recent-source metadata in `localStorage`, including labels, source kinds, + remote URLs, and local path-like display labels; and +- user-granted `FileSystemHandle` objects in the `lerobot-studio` IndexedDB + database when the browser supports handle persistence. + +Dataset payload bytes are not intentionally persisted in these stores by the +history feature. A stored handle does not bypass browser permission checks; the +browser may require the user to grant access again. Private browsing, +permissions, quota, or browser policy may disable persistence without disabling +all viewing. + +Users can remove recent items in the application. Clearing site data in browser +settings removes the application’s `localStorage` and IndexedDB data. Removing +browser storage does not delete source files or data held by remote hosts. + +## Network boundaries + +The standalone application may contact: + +- the URL explicitly opened by the user; +- configured sample-dataset endpoints; and +- same-origin application assets. + +The npm library must not introduce undisclosed network destinations. Embedding +applications should apply an appropriate Content Security Policy and document +their own sample endpoints, proxies, authentication, logging, and retention. + +## Sensitive datasets + +Do not assume that browser-local processing makes a dataset non-sensitive. +Filenames, paths, video frames, images, task descriptions, and sensor data may +contain personal or confidential information. Use a trusted device and origin, +review remote URLs before opening them, and follow the dataset owner’s access +and retention requirements. diff --git a/docs/quick-start.md b/docs/quick-start.md new file mode 100644 index 0000000..a4f73bb --- /dev/null +++ b/docs/quick-start.md @@ -0,0 +1,76 @@ +# Quick Start + +LeRobot Studio has two entry points: + +- the hosted application at [lerobot.studio](https://lerobot.studio); and +- the browser-only React package, after `@ioai/lerobot-studio` is published. + +## Use the hosted viewer + +Open the live application and choose a local folder, local archive, or remote +archive. Local content is processed in the browser. Remote archives must meet +the documented [CORS and HTTP Range requirements](./cors.md). + +## Embed the React viewer + +::: warning Publication status +`@ioai/lerobot-studio@1.0.0` is not published yet. Installation and the stable +contract below become available only after the [1.0 release gates](./compatibility.md#_1-0-acceptance-gates) +pass. +::: + +```bash +npm install @ioai/lerobot-studio +``` + +```tsx +import { LeRobotViewer } from '@ioai/lerobot-studio'; +import '@ioai/lerobot-studio/style.css'; + +export function DatasetViewer() { + return ( +
+ +
+ ); +} +``` + +The host must give the viewer an explicit height. The component fills its +container and does not choose a page height. + +## Runtime contract + +- React and React DOM `^19.0.0` +- ESM only +- browser and client-side rendering only +- no Node.js, CommonJS, React Server Components, SSR, or hydration support + +In Next.js, load the viewer from a Client Component with SSR disabled: + +```tsx +'use client'; + +import dynamic from 'next/dynamic'; + +const Viewer = dynamic( + () => import('@ioai/lerobot-studio').then((module) => module.LeRobotViewer), + { ssr: false }, +); +``` + +See the [stable API](./api.md) for custom data sources, callbacks, and errors. + +## Develop this repository + +```bash +npm ci +npm run fixtures:generate +npm run dev +``` + +To work on this documentation site: + +```bash +npm run docs:dev +``` diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..6cddc23 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,58 @@ +# Troubleshooting + +## The viewer has zero height + +`LeRobotViewer` fills its container. Give the host element an explicit height +or a layout constraint that resolves to a non-zero height. + +```tsx +
+ +
+``` + +## A remote archive does not open + +1. Confirm the final URL is reachable with `GET` from the viewer origin. +2. Inspect redirects; every final response must preserve the required CORS + headers. +3. Send a small byte request and verify a `206 Partial Content` response with a + valid `Content-Range`. +4. Expose `Accept-Ranges`, `Content-Length`, and `Content-Range` to browser + code. +5. Check whether a signed URL expired or a CDN removed the `Range` header. + +See [CORS and HTTP Range](./cors.md) for example headers. + +## An archive type is rejected + +The public archive factory accepts ZIP, TAR, and TAR.GZ files. For remote +sources, use a URL and response that allow the archive kind to be identified. +Use `onFatalError` to distinguish `UNSUPPORTED_ARCHIVE` from network and +dataset failures. + +## A dataset opens with a warning + +Only exact declared versions `v2.1` and `v3.0` are intended for 1.0 support. +Any other or missing `codebase_version` is unknown. Safe inspection may remain +available in warning-marked read-only mode, but export must stay disabled. + +## Video is unavailable + +Playback depends on the browser and codec used by the dataset. Test the same +asset in the target browser and inspect its media errors. A viewer smoke test +does not establish support for every codec. Non-media data should remain +inspectable where safe. + +## Next.js fails during server rendering + +Render the viewer from a Client Component and load it dynamically with +`ssr: false`. The package is browser-only and does not support React Server +Components or server rendering. See [Quick Start](./quick-start.md). + +## A custom data source leaks memory + +Revoke generated object URLs in `invalidateObjectUrl()` or `clear()`, release +archive handles, and ensure repeated reads do not retain full buffers without a +bounded cache. The host owns the lifecycle of a custom +[`DataSource`](./api.md). diff --git a/eslint.config.js b/eslint.config.js index b7d2d7f..9669df6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -13,8 +13,6 @@ export default defineConfig([ 'node_modules/**', 'tests/fixtures/datasets/**', '**/*.d.ts', - // Pre-monorepo leftover tree; packages/* + apps/* are authoritative. - 'src/**', ]), { files: ['**/*.{ts,tsx}'], @@ -44,8 +42,25 @@ export default defineConfig([ '@typescript-eslint/no-explicit-any': 'off', // ESLint 10 新增:既有 Error 包装模式未统一 attach cause 'preserve-caught-error': 'off', - // shadcn / Context 模式会同时导出组件与 hook/variants - 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + // Context hooks and shadcn variants intentionally share their component module. + 'react-refresh/only-export-components': [ + 'warn', + { + allowConstantExport: true, + allowExportNames: [ + 'useDockviewApi', + 'useLoading', + 'useToast', + 'useI18nController', + 'useTranslationBridge', + 'reportIntlError', + 'badgeVariants', + 'buttonVariants', + 'tabsListVariants', + 'usePortalContainer', + ], + }, + ], }, }, ]); diff --git a/etc/lerobot-studio.api.md b/etc/lerobot-studio.api.md new file mode 100644 index 0000000..c3927e9 --- /dev/null +++ b/etc/lerobot-studio.api.md @@ -0,0 +1,70 @@ +## API Report File for "@ioai/lerobot-studio" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { default as default_2 } from 'react'; + +// @public +export function createArchiveDataSourceFromFile(file: File): DataSource; + +// @public +export function createArchiveDataSourceFromUrl(url: string): DataSource; + +// @public +export interface DataSource { + clear(): void | Promise; + exists(path: string): Promise; + getObjectUrl(path: string, mimeType?: string, onProgress?: ProgressHandler): Promise; + invalidateObjectUrl?(path: string): void | Promise; + listPaths?(): Promise; + readBytes(path: string, onProgress?: ProgressHandler): Promise; + readText(path: string, onProgress?: ProgressHandler): Promise; +} + +// @public (undocumented) +export const LeRobotViewer: default_2.FC; + +// @public +export interface LeRobotViewerError { + cause?: unknown; + code: LeRobotViewerErrorCode; + message: string; + recoverable: boolean; +} + +// @public +export type LeRobotViewerErrorCode = 'INVALID_DATA_SOURCE' | 'REMOTE_SOURCE_UNAVAILABLE' | 'UNSUPPORTED_ARCHIVE' | 'DATASET_LOAD_FAILED'; + +// @public +export interface LeRobotViewerProps { + className?: string; + dataSource: string | DataSource; + enableKeyboardShortcuts?: boolean; + language?: string; + onBack?: () => void; + onExport?: () => void; + onFatalError?: (error: LeRobotViewerError) => void; + showPlaybackBar?: boolean; + showSidebar?: boolean; + theme?: 'light' | 'dark' | 'system'; +} + +// @public +export type LoadingPhase = 'download' | 'index' | 'gunzip' | 'read'; + +// @public +export type ProgressHandler = (info: ProgressInfo) => void; + +// @public +export interface ProgressInfo { + loaded?: number; + message?: string; + phase: LoadingPhase; + total?: number; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/examples/next-app-router/app/layout.tsx b/examples/next-app-router/app/layout.tsx new file mode 100644 index 0000000..ea19dcf --- /dev/null +++ b/examples/next-app-router/app/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next'; +import type { ReactNode } from 'react'; +import '@ioai/lerobot-studio/style.css'; +import './styles.css'; + +// Next.js requires route metadata to be exported from the layout module. +// eslint-disable-next-line react-refresh/only-export-components +export const metadata: Metadata = { + title: 'LeRobot Studio Next.js consumer', +}; + +export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) { + return ( + + {children} + + ); +} diff --git a/examples/next-app-router/app/page.tsx b/examples/next-app-router/app/page.tsx new file mode 100644 index 0000000..a8f34bd --- /dev/null +++ b/examples/next-app-router/app/page.tsx @@ -0,0 +1,5 @@ +import { ViewerClient } from '../components/ViewerClient'; + +export default function Home() { + return ; +} diff --git a/examples/next-app-router/app/styles.css b/examples/next-app-router/app/styles.css new file mode 100644 index 0000000..11e5d2d --- /dev/null +++ b/examples/next-app-router/app/styles.css @@ -0,0 +1,7 @@ +html, +body, +main { + width: 100%; + height: 100%; + margin: 0; +} diff --git a/examples/next-app-router/components/ViewerCanvas.tsx b/examples/next-app-router/components/ViewerCanvas.tsx new file mode 100644 index 0000000..d3d6217 --- /dev/null +++ b/examples/next-app-router/components/ViewerCanvas.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { LeRobotViewer, type DataSource } from '@ioai/lerobot-studio'; + +const files: Record = { + 'meta/info.json': JSON.stringify({ + codebase_version: 'v2.1', + robot_type: 'next-consumer-fixture', + total_episodes: 1, + total_frames: 1, + total_tasks: 1, + total_videos: 0, + chunks_size: 1000, + fps: 30, + features: { + timestamp: { dtype: 'float32', shape: [1], names: null }, + }, + }), + 'meta/episodes.jsonl': '{"episode_index":0,"length":1,"tasks":["smoke test"]}\n', + 'meta/tasks.jsonl': '{"task_index":0,"task":"smoke test"}\n', +}; + +const dataSource: DataSource = { + async exists(filePath) { + return filePath in files; + }, + async readText(filePath) { + const value = files[filePath]; + if (value === undefined) throw new Error(`Fixture path not found: ${filePath}`); + return value; + }, + async readBytes(filePath) { + return new TextEncoder().encode(await this.readText(filePath)); + }, + async getObjectUrl(filePath, mimeType = 'application/octet-stream') { + return URL.createObjectURL(new Blob([await this.readText(filePath)], { type: mimeType })); + }, + clear() {}, + async listPaths() { + return Object.keys(files); + }, +}; + +export default function ViewerCanvas() { + return ( +
+ +
+ ); +} diff --git a/examples/next-app-router/components/ViewerClient.tsx b/examples/next-app-router/components/ViewerClient.tsx new file mode 100644 index 0000000..2f017ba --- /dev/null +++ b/examples/next-app-router/components/ViewerClient.tsx @@ -0,0 +1,12 @@ +'use client'; + +import dynamic from 'next/dynamic'; + +const ViewerCanvas = dynamic(() => import('./ViewerCanvas'), { + ssr: false, + loading: () =>

Loading LeRobot Studio…

, +}); + +export function ViewerClient() { + return ; +} diff --git a/examples/next-app-router/next-env.d.ts b/examples/next-app-router/next-env.d.ts new file mode 100644 index 0000000..b45390a --- /dev/null +++ b/examples/next-app-router/next-env.d.ts @@ -0,0 +1,4 @@ +/// +/// + +// This file is generated by Next.js and is checked in so the example typechecks after install. diff --git a/examples/next-app-router/package.json b/examples/next-app-router/package.json new file mode 100644 index 0000000..43ba27b --- /dev/null +++ b/examples/next-app-router/package.json @@ -0,0 +1,23 @@ +{ + "name": "lerobot-studio-next-app-router-example", + "private": true, + "version": "0.0.0", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@ioai/lerobot-studio": "file:../..", + "next": "latest", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/node": "latest", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "latest" + } +} diff --git a/examples/next-app-router/tsconfig.json b/examples/next-app-router/tsconfig.json new file mode 100644 index 0000000..f9d454b --- /dev/null +++ b/examples/next-app-router/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": false, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }] + }, + "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/examples/vite-react/index.html b/examples/vite-react/index.html new file mode 100644 index 0000000..010197a --- /dev/null +++ b/examples/vite-react/index.html @@ -0,0 +1,12 @@ + + + + + + LeRobot Studio Vite consumer + + +
+ + + diff --git a/examples/vite-react/package.json b/examples/vite-react/package.json new file mode 100644 index 0000000..2863c7d --- /dev/null +++ b/examples/vite-react/package.json @@ -0,0 +1,24 @@ +{ + "name": "lerobot-studio-vite-react-example", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "typecheck": "tsc --noEmit", + "build": "npm run typecheck && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@ioai/lerobot-studio": "file:../..", + "@vitejs/plugin-react": "latest", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vite": "latest" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "typescript": "latest" + } +} diff --git a/examples/vite-react/src/App.tsx b/examples/vite-react/src/App.tsx new file mode 100644 index 0000000..8f34269 --- /dev/null +++ b/examples/vite-react/src/App.tsx @@ -0,0 +1,57 @@ +import { LeRobotViewer, type DataSource } from '@ioai/lerobot-studio'; + +const files = new Map([ + [ + 'meta/info.json', + JSON.stringify({ + codebase_version: 'v2.1', + robot_type: 'npm-consumer-fixture', + total_episodes: 1, + total_frames: 1, + total_tasks: 1, + total_videos: 0, + chunks_size: 1000, + fps: 30, + features: { + timestamp: { dtype: 'float32', shape: [1], names: null }, + }, + }), + ], + ['meta/episodes.jsonl', '{"episode_index":0,"length":1,"tasks":["smoke test"]}\n'], + ['meta/tasks.jsonl', '{"task_index":0,"task":"smoke test"}\n'], +]); + +const dataSource: DataSource = { + async exists(filePath) { + return files.has(filePath); + }, + async readText(filePath) { + if (filePath === 'meta/info.json') window.__LEROBOT_CONSUMER__.infoRead = true; + const value = files.get(filePath); + if (value === undefined) throw new Error(`Fixture path not found: ${filePath}`); + return value; + }, + async readBytes(filePath) { + return new TextEncoder().encode(await this.readText(filePath)); + }, + async getObjectUrl(filePath, mimeType = 'application/octet-stream') { + return URL.createObjectURL(new Blob([await this.readText(filePath)], { type: mimeType })); + }, + clear() {}, + async listPaths() { + return [...files.keys()]; + }, +}; + +export function App() { + return ( +
+ +
+ ); +} diff --git a/examples/vite-react/src/main.tsx b/examples/vite-react/src/main.tsx new file mode 100644 index 0000000..d38cbd8 --- /dev/null +++ b/examples/vite-react/src/main.tsx @@ -0,0 +1,44 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import '@ioai/lerobot-studio/style.css'; +import { App } from './App'; +import './styles.css'; + +declare global { + interface Window { + __LEROBOT_CONSUMER__: { + infoRead: boolean; + wasmFetched: boolean; + workerConstructed: boolean; + }; + } +} + +window.__LEROBOT_CONSUMER__ = { + infoRead: false, + wasmFetched: false, + workerConstructed: false, +}; +const nativeFetch = window.fetch.bind(window); +window.fetch = async (...args) => { + const input = args[0]; + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const isWasm = url.startsWith('data:') + ? url.includes('wasm') + : new URL(url).pathname.endsWith('.wasm'); + const response = await nativeFetch(...args); + if (isWasm && response.ok) window.__LEROBOT_CONSUMER__.wasmFetched = true; + return response; +}; +window.Worker = new Proxy(window.Worker, { + construct(Target, args) { + window.__LEROBOT_CONSUMER__.workerConstructed = true; + return Reflect.construct(Target, args); + }, +}); + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/examples/vite-react/src/styles.css b/examples/vite-react/src/styles.css new file mode 100644 index 0000000..6410ea7 --- /dev/null +++ b/examples/vite-react/src/styles.css @@ -0,0 +1,8 @@ +html, +body, +#root, +main { + width: 100%; + height: 100%; + margin: 0; +} diff --git a/examples/vite-react/tsconfig.json b/examples/vite-react/tsconfig.json new file mode 100644 index 0000000..07a1611 --- /dev/null +++ b/examples/vite-react/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": false, + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/examples/vite-react/vite.config.ts b/examples/vite-react/vite.config.ts new file mode 100644 index 0000000..ea3bc2e --- /dev/null +++ b/examples/vite-react/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + base: './', + plugins: [react()], +}); diff --git a/package-lock.json b/package-lock.json index 6eccd2e..7f1b785 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,10 @@ "use-sync-external-store": "^1.6.0" }, "devDependencies": { + "@arethetypeswrong/cli": "^0.18.5", + "@changesets/cli": "^2.31.1", "@eslint/js": "^10.0.1", + "@microsoft/api-extractor": "^7.58.12", "@playwright/test": "^1.62.0", "@tailwindcss/vite": "^4.3.3", "@types/node": "^26.1.2", @@ -43,6 +46,7 @@ "@vitest/browser": "^4.1.10", "@vitest/browser-playwright": "^4.1.10", "@vitest/coverage-v8": "^4.1.10", + "axe-core": "^4.12.1", "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", @@ -50,6 +54,7 @@ "lightningcss": "^1.33.0", "playwright": "^1.62.0", "prettier": "^3.6.2", + "publint": "^0.3.23", "react": "^19.2.8", "react-dom": "^19.2.8", "shadcn": "^4.16.0", @@ -58,14 +63,12 @@ "typescript-eslint": "^8.65.0", "vite": "^8.1.5", "vite-plugin-dts": "^5.0.3", + "vitepress": "2.0.0-alpha.19", "vitest": "^4.1.10" }, - "engines": { - "node": ">=24.0.0 <25" - }, "peerDependencies": { - "react": "^19.2.8", - "react-dom": "^19.2.8" + "react": "^19.0.0", + "react-dom": "^19.0.0" } }, "apps/web": { @@ -92,6 +95,141 @@ "vite": "^8.1.5" } }, + "node_modules/@andrewbranch/untar.js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@andrewbranch/untar.js/-/untar.js-1.0.3.tgz", + "integrity": "sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw==", + "dev": true + }, + "node_modules/@arethetypeswrong/cli": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@arethetypeswrong/cli/-/cli-0.18.5.tgz", + "integrity": "sha512-gM+8vRsQOD/Uc7EnBedUhkG5OCsDWE4uoak5QvomGpMpaky0Eh41p04nIMgrWb8EOmqZUJGc6zz9hsP6E56R7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@arethetypeswrong/core": "0.18.5", + "chalk": "^4.1.2", + "cli-table3": "^0.6.3", + "commander": "^10.0.1", + "marked": "^9.1.2", + "marked-terminal": "^7.1.0", + "semver": "^7.5.4" + }, + "bin": { + "attw": "dist/index.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@arethetypeswrong/cli/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@arethetypeswrong/cli/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@arethetypeswrong/cli/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/@arethetypeswrong/core": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/@arethetypeswrong/core/-/core-0.18.5.tgz", + "integrity": "sha512-9ytjzGwxjm9Uz7I9avfbt5vlQt6uk9uRRESzJjqrznl6WKvI6dwYTo+vJ3U02Wrq/mR3iql/PzhvHhKdJIAjDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@andrewbranch/untar.js": "^1.0.3", + "@loaderkit/resolve": "^1.0.2", + "cjs-module-lexer": "^1.2.3", + "fflate": "^0.8.3", + "lru-cache": "^11.0.1", + "semver": "^7.5.4", + "typescript": "5.6.1-rc", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@arethetypeswrong/core/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@arethetypeswrong/core/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/@arethetypeswrong/core/node_modules/typescript": { + "version": "5.6.1-rc", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.1-rc.tgz", + "integrity": "sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@arethetypeswrong/core/node_modules/validate-npm-package-name": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", + "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -611,182 +749,547 @@ "dev": true, "license": "MIT" }, - "node_modules/@dotenvx/dotenvx": { - "version": "1.75.1", - "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz", - "integrity": "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@dotenvx/primitives": "^0.8.0", - "commander": "^11.1.0", - "conf": "^10.2.0", - "dotenv": "^17.2.1", - "enquirer": "^2.4.1", - "env-paths": "^2.2.1", - "execa": "^5.1.1", - "fdir": "^6.2.0", - "ignore": "^5.3.0", - "object-treeify": "1.1.33", - "open": "^8.4.2", - "picomatch": "^4.0.4", - "systeminformation": "^5.22.11", - "undici": "^7.11.0", - "which": "^4.0.0", - "yocto-spinner": "^1.1.0" - }, - "bin": { - "dotenvx": "src/cli/dotenvx.js" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/@dotenvx/dotenvx/node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "node_modules/@braidai/lang": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@braidai/lang/-/lang-1.1.2.tgz", + "integrity": "sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - } + "license": "ISC" }, - "node_modules/@dotenvx/dotenvx/node_modules/define-lazy-prop": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", - "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" + "node_modules/@changesets/apply-release-plan": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.1.1.tgz", + "integrity": "sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/config": "^3.1.4", + "@changesets/get-version-range-type": "^0.4.0", + "@changesets/git": "^3.0.4", + "@changesets/should-skip-package": "^0.1.2", + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3", + "detect-indent": "^6.0.0", + "fs-extra": "^7.0.1", + "lodash.startcase": "^4.4.0", + "outdent": "^0.5.0", + "prettier": "^2.7.1", + "resolve-from": "^5.0.0", + "semver": "^7.5.3" } }, - "node_modules/@dotenvx/dotenvx/node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "node_modules/@changesets/apply-release-plan/node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", "dev": true, "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" + "bin": { + "prettier": "bin-prettier.js" }, "engines": { - "node": ">=10" + "node": ">=10.13.0" }, "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" + "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/@dotenvx/dotenvx/node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "node_modules/@changesets/apply-release-plan/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": "MIT", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@dotenvx/dotenvx/node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "node_modules/@changesets/assemble-release-plan": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/@changesets/assemble-release-plan/-/assemble-release-plan-6.0.10.tgz", + "integrity": "sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" + "license": "MIT", + "dependencies": { + "@changesets/errors": "^0.2.0", + "@changesets/get-dependents-graph": "^2.1.4", + "@changesets/should-skip-package": "^0.1.2", + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3", + "semver": "^7.5.3" } }, - "node_modules/@dotenvx/dotenvx/node_modules/is-docker": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", - "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "node_modules/@changesets/assemble-release-plan/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": "MIT", + "license": "ISC", "bin": { - "is-docker": "cli.js" + "semver": "bin/semver.js" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=10" } }, - "node_modules/@dotenvx/dotenvx/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "node_modules/@changesets/changelog-git": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@changesets/changelog-git/-/changelog-git-0.2.1.tgz", + "integrity": "sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "@changesets/types": "^6.1.0" } }, - "node_modules/@dotenvx/dotenvx/node_modules/is-wsl": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", - "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "node_modules/@changesets/cli": { + "version": "2.31.1", + "resolved": "https://registry.npmjs.org/@changesets/cli/-/cli-2.31.1.tgz", + "integrity": "sha512-uO05WTcRBwuVOJVSW8Cmpqw6q0WDL53ajGCMyszutvOe5toOnunbpM4jZzf+qxBOz7i0AzopZ8diBuewjmF40w==", "dev": true, "license": "MIT", "dependencies": { - "is-docker": "^2.0.0" + "@changesets/apply-release-plan": "^7.1.1", + "@changesets/assemble-release-plan": "^6.0.10", + "@changesets/changelog-git": "^0.2.1", + "@changesets/config": "^3.1.4", + "@changesets/errors": "^0.2.0", + "@changesets/get-dependents-graph": "^2.1.4", + "@changesets/get-release-plan": "^4.0.16", + "@changesets/git": "^3.0.4", + "@changesets/logger": "^0.1.1", + "@changesets/pre": "^2.0.2", + "@changesets/read": "^0.6.7", + "@changesets/should-skip-package": "^0.1.2", + "@changesets/types": "^6.1.0", + "@changesets/write": "^0.4.0", + "@inquirer/external-editor": "^1.0.2", + "@manypkg/get-packages": "^1.1.3", + "ansi-colors": "^4.1.3", + "enquirer": "^2.4.1", + "fs-extra": "^7.0.1", + "mri": "^1.2.0", + "package-manager-detector": "^0.2.0", + "picocolors": "^1.1.0", + "resolve-from": "^5.0.0", + "semver": "^7.5.3", + "spawndamnit": "^3.0.1", + "term-size": "^2.1.0" }, - "engines": { - "node": ">=8" + "bin": { + "changeset": "bin.js" } }, - "node_modules/@dotenvx/dotenvx/node_modules/isexe": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "node_modules/@changesets/cli/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": "BlueOak-1.0.0", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, "engines": { - "node": ">=18" + "node": ">=10" } }, - "node_modules/@dotenvx/dotenvx/node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "node_modules/@changesets/config": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@changesets/config/-/config-3.1.4.tgz", + "integrity": "sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==", "dev": true, "license": "MIT", "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" + "@changesets/errors": "^0.2.0", + "@changesets/get-dependents-graph": "^2.1.4", + "@changesets/logger": "^0.1.1", + "@changesets/should-skip-package": "^0.1.2", + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3", + "fs-extra": "^7.0.1", + "micromatch": "^4.0.8" } }, - "node_modules/@dotenvx/dotenvx/node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "node_modules/@changesets/errors": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@changesets/errors/-/errors-0.2.0.tgz", + "integrity": "sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==", "dev": true, "license": "MIT", "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" + "extendable-error": "^0.1.5" + } + }, + "node_modules/@changesets/get-dependents-graph": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@changesets/get-dependents-graph/-/get-dependents-graph-2.1.4.tgz", + "integrity": "sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3", + "picocolors": "^1.1.0", + "semver": "^7.5.3" + } + }, + "node_modules/@changesets/get-dependents-graph/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/@changesets/get-release-plan": { + "version": "4.0.16", + "resolved": "https://registry.npmjs.org/@changesets/get-release-plan/-/get-release-plan-4.0.16.tgz", + "integrity": "sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/assemble-release-plan": "^6.0.10", + "@changesets/config": "^3.1.4", + "@changesets/pre": "^2.0.2", + "@changesets/read": "^0.6.7", + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3" + } + }, + "node_modules/@changesets/get-version-range-type": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@changesets/get-version-range-type/-/get-version-range-type-0.4.0.tgz", + "integrity": "sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@changesets/git": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@changesets/git/-/git-3.0.4.tgz", + "integrity": "sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/errors": "^0.2.0", + "@manypkg/get-packages": "^1.1.3", + "is-subdir": "^1.1.1", + "micromatch": "^4.0.8", + "spawndamnit": "^3.0.1" + } + }, + "node_modules/@changesets/logger": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@changesets/logger/-/logger-0.1.1.tgz", + "integrity": "sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.1.0" + } + }, + "node_modules/@changesets/parse": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@changesets/parse/-/parse-0.4.3.tgz", + "integrity": "sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^6.1.0", + "js-yaml": "^4.1.1" + } + }, + "node_modules/@changesets/pre": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@changesets/pre/-/pre-2.0.2.tgz", + "integrity": "sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/errors": "^0.2.0", + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3", + "fs-extra": "^7.0.1" + } + }, + "node_modules/@changesets/read": { + "version": "0.6.7", + "resolved": "https://registry.npmjs.org/@changesets/read/-/read-0.6.7.tgz", + "integrity": "sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/git": "^3.0.4", + "@changesets/logger": "^0.1.1", + "@changesets/parse": "^0.4.3", + "@changesets/types": "^6.1.0", + "fs-extra": "^7.0.1", + "p-filter": "^2.1.0", + "picocolors": "^1.1.0" + } + }, + "node_modules/@changesets/should-skip-package": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@changesets/should-skip-package/-/should-skip-package-0.1.2.tgz", + "integrity": "sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^6.1.0", + "@manypkg/get-packages": "^1.1.3" + } + }, + "node_modules/@changesets/types": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@changesets/types/-/types-6.1.0.tgz", + "integrity": "sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@changesets/write": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@changesets/write/-/write-0.4.0.tgz", + "integrity": "sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@changesets/types": "^6.1.0", + "fs-extra": "^7.0.1", + "human-id": "^4.1.1", + "prettier": "^2.7.1" + } + }, + "node_modules/@changesets/write/node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@docsearch/css": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-4.7.0.tgz", + "integrity": "sha512-Sk5xkdRFeE7PeWjG9l4AfTwdvMfr9wHiwNNCpHXT4v4SNyNMKdHGvEILc31BgaVFGDDNbv5u/a73tofRiwbEZw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@docsearch/js": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-4.7.0.tgz", + "integrity": "sha512-x5lCqu1tetgsJFkjQ6VSocbHldsRkGEgwg5N98Vx21sq/V5wcmj4u226PY9k+TEpIgQ772zlYbPLTPicWyGnpA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@docsearch/sidepanel-js": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@docsearch/sidepanel-js/-/sidepanel-js-4.7.0.tgz", + "integrity": "sha512-A8r34jCU8kcIk2viECEn2msA28ojUF1BLi/3v5OWWc5G2N3jOuuumBXoeYjfr8dA0UxgFSy5R2bt12dnFJQSyA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@dotenvx/dotenvx": { + "version": "1.75.1", + "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz", + "integrity": "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@dotenvx/primitives": "^0.8.0", + "commander": "^11.1.0", + "conf": "^10.2.0", + "dotenv": "^17.2.1", + "enquirer": "^2.4.1", + "env-paths": "^2.2.1", + "execa": "^5.1.1", + "fdir": "^6.2.0", + "ignore": "^5.3.0", + "object-treeify": "1.1.33", + "open": "^8.4.2", + "picomatch": "^4.0.4", + "systeminformation": "^5.22.11", + "undici": "^7.11.0", + "which": "^4.0.0", + "yocto-spinner": "^1.1.0" + }, + "bin": { + "dotenvx": "src/cli/dotenvx.js" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@dotenvx/dotenvx/node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -1152,6 +1655,45 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@iconify-json/simple-icons": { + "version": "1.2.93", + "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.93.tgz", + "integrity": "sha512-/XhANjfGYOuqvSR3TmUnkQkINvQ4GVjVuukvymRbxtVFBvIq/yiXJqCDycKcQPT401OYT9H2vIY6ihAlz1QIAw==", + "dev": true, + "license": "CC0-1.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1202,6 +1744,268 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@loaderkit/resolve": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@loaderkit/resolve/-/resolve-1.0.6.tgz", + "integrity": "sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@braidai/lang": "^1.0.0" + } + }, + "node_modules/@manypkg/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.5.5", + "@types/node": "^12.7.1", + "find-up": "^4.1.0", + "fs-extra": "^8.1.0" + } + }, + "node_modules/@manypkg/find-root/node_modules/@types/node": { + "version": "12.20.55", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", + "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@manypkg/find-root/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@manypkg/find-root/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@manypkg/find-root/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@manypkg/find-root/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@manypkg/find-root/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@manypkg/get-packages": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@manypkg/get-packages/-/get-packages-1.1.3.tgz", + "integrity": "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.5.5", + "@changesets/types": "^4.0.1", + "@manypkg/find-root": "^1.1.0", + "fs-extra": "^8.1.0", + "globby": "^11.0.0", + "read-yaml-file": "^1.1.0" + } + }, + "node_modules/@manypkg/get-packages/node_modules/@changesets/types": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@changesets/types/-/types-4.1.0.tgz", + "integrity": "sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@manypkg/get-packages/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@microsoft/api-extractor": { + "version": "7.58.12", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.58.12.tgz", + "integrity": "sha512-VNpgC/1LaroLbn+UKmwDYspq+9r3EHyj4vJ3qUy/g8vB0rKt+1Wgs7WFj/JGpgxhG8SNyXs1XwYwe7nqkk8IDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/api-extractor-model": "7.33.10", + "@microsoft/tsdoc": "~0.16.0", + "@microsoft/tsdoc-config": "~0.18.1", + "@rushstack/node-core-library": "5.23.3", + "@rushstack/rig-package": "0.7.3", + "@rushstack/terminal": "0.24.2", + "@rushstack/ts-command-line": "5.3.12", + "diff": "~8.0.2", + "minimatch": "10.2.3", + "resolve": "~1.22.1", + "semver": "~7.7.4", + "source-map": "~0.6.1", + "typescript": "5.9.3" + }, + "bin": { + "api-extractor": "bin/api-extractor" + } + }, + "node_modules/@microsoft/api-extractor-model": { + "version": "7.33.10", + "resolved": "https://registry.npmjs.org/@microsoft/api-extractor-model/-/api-extractor-model-7.33.10.tgz", + "integrity": "sha512-uPUK17xGxeQ3av6TN7awp+dTTSTvx8fZC0XFL1gyt7hFapYuxND3XLXH8vkmI7UjfW92oogzFAFqHSifmJROaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "~0.16.0", + "@microsoft/tsdoc-config": "~0.18.1", + "@rushstack/node-core-library": "5.23.3" + } + }, + "node_modules/@microsoft/api-extractor/node_modules/minimatch": { + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz", + "integrity": "sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@microsoft/api-extractor/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@microsoft/api-extractor/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@microsoft/tsdoc": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@microsoft/tsdoc-config": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.18.1.tgz", + "integrity": "sha512-9brPoVdfN9k9g0dcWkFeA7IH9bbcttzDJlXvkf8b2OBzd5MueR1V2wkKBL0abn0otvmkHJC6aapBOTJDDeMCZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.16.0", + "ajv": "~8.18.0", + "jju": "~1.4.0", + "resolve": "~1.22.2" + } + }, + "node_modules/@microsoft/tsdoc-config/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@microsoft/tsdoc-config/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", @@ -1328,9 +2132,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", "dev": true, "license": "MIT", "funding": { @@ -1360,6 +2164,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@publint/pack": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.6.tgz", + "integrity": "sha512-3uVNyGcVplhPZSLVyeIpL7+cIRn1YCSNHLG/rUIlBQMVH8YuN9++YF+5+UDIIO9RW98dujiUoTltO7RDB5bFJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyexec": "^1.2.4" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://bjornlu.com/sponsor" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.3.tgz", @@ -2857,9 +3677,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz", + "integrity": "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==", "cpu": [ "arm64" ], @@ -2874,9 +3694,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.2.tgz", + "integrity": "sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==", "cpu": [ "arm64" ], @@ -2891,9 +3711,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.2.tgz", + "integrity": "sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==", "cpu": [ "x64" ], @@ -2908,9 +3728,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.2.tgz", + "integrity": "sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==", "cpu": [ "x64" ], @@ -2925,9 +3745,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.2.tgz", + "integrity": "sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==", "cpu": [ "arm" ], @@ -2942,9 +3762,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.2.tgz", + "integrity": "sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==", "cpu": [ "arm64" ], @@ -2959,9 +3779,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.2.tgz", + "integrity": "sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==", "cpu": [ "arm64" ], @@ -2976,9 +3796,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.2.tgz", + "integrity": "sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==", "cpu": [ "ppc64" ], @@ -2993,9 +3813,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.2.tgz", + "integrity": "sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==", "cpu": [ "s390x" ], @@ -3010,9 +3830,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.2.tgz", + "integrity": "sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==", "cpu": [ "x64" ], @@ -3027,9 +3847,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.2.tgz", + "integrity": "sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==", "cpu": [ "x64" ], @@ -3044,9 +3864,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.2.tgz", + "integrity": "sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==", "cpu": [ "arm64" ], @@ -3060,102 +3880,418 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.2.tgz", + "integrity": "sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==", + "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.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.2.tgz", + "integrity": "sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==", + "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/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rushstack/node-core-library": { + "version": "5.23.3", + "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.23.3.tgz", + "integrity": "sha512-f6uuza7Um65bwsIJgf0MRs7IPA5IG+A+zs1AYGQvpZmLjtTGdfHowhQw4kwF0pPhJCrU4UHNhK8Qa6tLygYZCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "~8.20.0", + "ajv-draft-04": "~1.0.0", + "ajv-formats": "~3.0.1", + "fs-extra": "~11.3.0", + "import-lazy": "~4.0.0", + "jju": "~1.4.0", + "resolve": "~1.22.1", + "semver": "~7.7.4" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@rushstack/node-core-library/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@rushstack/node-core-library/node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@rushstack/node-core-library/node_modules/fs-extra": { + "version": "11.3.6", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", + "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@rushstack/node-core-library/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rushstack/node-core-library/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/@rushstack/node-core-library/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@rushstack/node-core-library/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/@rushstack/problem-matcher": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@rushstack/problem-matcher/-/problem-matcher-0.2.1.tgz", + "integrity": "sha512-gulfhBs6n+I5b7DvjKRfhMGyUejtSgOHTclF/eONr8hcgF1APEDjhxIsfdUYYMzC3rvLwGluqLjbwCFZ8nxrog==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@rushstack/rig-package": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@rushstack/rig-package/-/rig-package-0.7.3.tgz", + "integrity": "sha512-aAA518n6wxxjCfnTAOjQnm7ngNE0FVHxHAw2pxKlIhxrMn0XQjGcXKF0oKWpjBgJOmsaJpVob/v+zr3zxgPWuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jju": "~1.4.0", + "resolve": "~1.22.1" + } + }, + "node_modules/@rushstack/terminal": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@rushstack/terminal/-/terminal-0.24.2.tgz", + "integrity": "sha512-KB7PpvzDyKMw/RGU3TxOwxTs3OwZ4gq6+WHlTJN/JfQH4ezliNtWIqver78jTaAJyz/ZAAlJGH7a/M1WyFLFSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rushstack/node-core-library": "5.23.3", + "@rushstack/problem-matcher": "0.2.1", + "supports-color": "~8.1.1" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@rushstack/terminal/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/@rushstack/ts-command-line": { + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@rushstack/ts-command-line/-/ts-command-line-5.3.12.tgz", + "integrity": "sha512-Vg2n24arSf7JvUNga2DMHYTbxnVq9L5OtVCp4Gfr8YC/kmL/bmdR8FQhGcpMzMYNY9Vdw3+TaimG11Sf+z1Tpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rushstack/terminal": "0.24.2", + "@types/argparse": "1.0.38", + "argparse": "~1.0.9", + "string-argv": "~0.3.1" + } + }, + "node_modules/@rushstack/ts-command-line/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@sec-ant/readable-stream": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@shikijs/core": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.1.tgz", + "integrity": "sha512-VeR2CY6Nn9/WbisoYLOQZ7HZOnwTrpBuOw4wExjqLnBCi62BNWynBUO6K2uPIASPFJwAv7cX1fUu+LrPlSstcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.4.1", + "@shikijs/types": "4.4.1", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.1.tgz", + "integrity": "sha512-6U4lJBh8LTvIkEVqRHv/rr3ruwtO6IweFQt1ME1ntHJMGHS+6N86vfYGO1o8c/DtOCTia2lfhdQBtBrps1sDfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.1", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.1.tgz", + "integrity": "sha512-p23RugMKss0r5DAtRJW1yAXUDl60JvhQYV20yuxei//26JyDSJefV3umyWzzwep2weblMnJGDYahuti6XkcMgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.1", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.1.tgz", + "integrity": "sha512-xb2kCMloBCIraIy2fS5MW0t/BxVY3q2nDyQKBoeSeq6KNrQbShHetCFlw2n35fGIJ6t3+hXDLQogP5ir9O9bvA==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" + "@shikijs/types": "4.4.1" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20" } }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", - "cpu": [ - "arm64" - ], + "node_modules/@shikijs/primitive": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.1.tgz", + "integrity": "sha512-ko2OfDoG89YuQ7xL5LtcQiWKb7NIv1Ephb7g48TVU198OzAMLC8lXVEwaJGHK4sUMYrfAGJDqYmNLOLiW/Kz8w==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@shikijs/types": "4.4.1", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20" } }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", - "cpu": [ - "x64" - ], + "node_modules/@shikijs/themes": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.1.tgz", + "integrity": "sha512-wudOaoFro+/Zl9gQv2W1Ur5XlVduqvTuYLI483Xi0wgc1A+cy1hfB2r6ac6ufBgF+ID7KJEW7L41MHrzQ4wH+w==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "dependencies": { + "@shikijs/types": "4.4.1" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20" } }, - "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==", + "node_modules/@shikijs/transformers": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-4.4.1.tgz", + "integrity": "sha512-Sb9Eehas+5EhClpFgNuklwY3aWf354FLaKRCiAWmjdNbHAjoQUpv6WmSj+N19eTXO6GLIWh1dIOH9dxyauhVWw==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.4.1", + "@shikijs/types": "4.4.1" + }, + "engines": { + "node": ">=20" + } }, - "node_modules/@rollup/pluginutils": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", - "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "node_modules/@shikijs/types": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.1.tgz", + "integrity": "sha512-GOwCLQDHM5EjGUWNPrhzJbr6JP8V/Dx/CDVkWvbZ1Avw5JFnNUckrgbLmE07qtg4WlW7Q7QFndhjIkeU9XMPvw==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" }, "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "node": ">=20" } }, - "node_modules/@rollup/pluginutils/node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "dev": true, "license": "MIT" }, - "node_modules/@sec-ant/readable-stream": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", - "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==", + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } }, "node_modules/@sindresorhus/merge-streams": { "version": "4.0.0", @@ -3733,6 +4869,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/argparse": { + "version": "1.0.38", + "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", + "integrity": "sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3780,6 +4923,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3787,6 +4940,41 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "26.1.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", @@ -3816,6 +5004,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -3823,6 +5018,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.65.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", @@ -4066,6 +5268,13 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "dev": true, + "license": "ISC" + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.4.tgz", @@ -4092,6 +5301,23 @@ } } }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", + "integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, "node_modules/@vitest/browser": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.10.tgz", @@ -4265,51 +5491,310 @@ "dev": true, "license": "MIT", "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz", + "integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/shared": "3.5.40", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz", + "integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz", + "integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@vue/compiler-core": "3.5.40", + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-ssr": "3.5.40", + "@vue/shared": "3.5.40", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz", + "integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.2.1.tgz", + "integrity": "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.2.1" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz", + "integrity": "sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.2.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz", + "integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz", + "integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.40.tgz", + "integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz", + "integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.40", + "@vue/runtime-core": "3.5.40", + "@vue/shared": "3.5.40", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.40.tgz", + "integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/shared": "3.5.40" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.40.tgz", + "integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "14.4.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.4.0.tgz", + "integrity": "sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.4.0", + "@vueuse/shared": "14.4.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" } }, - "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "node_modules/@vueuse/integrations": { + "version": "14.4.0", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-14.4.0.tgz", + "integrity": "sha512-oJz9qTgczvA7L1nXQFRU7h8tQbOCoiceqvMMhT9XYMyOGTqLJ2rEa09PON+nD2t48sZUfeOmg4eaWJXV4sZb/w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" + "@vueuse/core": "14.4.0", + "@vueuse/shared": "14.4.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7 || ^8", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7 || ^8", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } } }, - "node_modules/@volar/language-core": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", - "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "node_modules/@vueuse/metadata": { + "version": "14.4.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.4.0.tgz", + "integrity": "sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==", "dev": true, "license": "MIT", - "dependencies": { - "@volar/source-map": "2.4.28" + "funding": { + "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@volar/source-map": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", - "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@volar/typescript": { - "version": "2.4.28", - "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", - "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "node_modules/@vueuse/shared": { + "version": "14.4.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.4.0.tgz", + "integrity": "sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==", "dev": true, "license": "MIT", - "dependencies": { - "@volar/language-core": "2.4.28", - "path-browserify": "^1.0.1", - "vscode-uri": "^3.0.8" + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" } }, "node_modules/@zip.js/zip.js": { @@ -4429,6 +5914,22 @@ "node": ">=6" } }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -4439,6 +5940,29 @@ "node": ">=8" } }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, "node_modules/apache-arrow": { "version": "21.2.0", "resolved": "https://registry.npmjs.org/apache-arrow/-/apache-arrow-21.2.0.tgz", @@ -4488,6 +6012,16 @@ "node": ">=10" } }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -4540,6 +6074,16 @@ "node": ">=10.12.0" } }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -4563,6 +6107,29 @@ "node": ">=6.0.0" } }, + "node_modules/better-path-resolve": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/better-path-resolve/-/better-path-resolve-1.0.0.tgz", + "integrity": "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-windows": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -4603,9 +6170,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -4750,6 +6317,17 @@ ], "license": "CC-BY-4.0" }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -4773,6 +6351,52 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "dev": true, + "license": "MIT" + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -4801,6 +6425,45 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-highlight": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", + "integrity": "sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==", + "dev": true, + "license": "ISC", + "dependencies": { + "chalk": "^4.0.0", + "highlight.js": "^10.7.1", + "mz": "^2.4.0", + "parse5": "^5.1.1", + "parse5-htmlparser2-tree-adapter": "^6.0.0", + "yargs": "^16.0.0" + }, + "bin": { + "highlight": "bin/highlight" + }, + "engines": { + "node": ">=8.0.0", + "npm": ">=5.0.0" + } + }, + "node_modules/cli-highlight/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/cli-spinners": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", @@ -4814,6 +6477,78 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-table3/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/cli-table3/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -4830,12 +6565,43 @@ "dev": true, "license": "MIT" }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/comlink": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/comlink/-/comlink-4.4.2.tgz", "integrity": "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==", "license": "Apache-2.0" }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -5196,6 +6962,26 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-indent": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", + "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -5212,6 +6998,20 @@ "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", "license": "MIT" }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/diff": { "version": "8.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", @@ -5222,6 +7022,19 @@ "node": ">=0.3.1" } }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/dockview": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/dockview/-/dockview-7.0.4.tgz", @@ -5315,6 +7128,13 @@ "dev": true, "license": "MIT" }, + "node_modules/emojilib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/emojilib/-/emojilib-2.4.0.tgz", + "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", + "dev": true, + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -5353,6 +7173,19 @@ "node": ">=8.6" } }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -5363,6 +7196,19 @@ "node": ">=6" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -5793,6 +7639,13 @@ "dev": true, "license": "MIT" }, + "node_modules/extendable-error": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/extendable-error/-/extendable-error-0.1.7.tgz", + "integrity": "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6003,6 +7856,16 @@ "dev": true, "license": "ISC" }, + "node_modules/focus-trap": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-8.2.2.tgz", + "integrity": "sha512-qV0g8hRYBqgACcFOH3f9wXc4zPKhr/0z9RI2a6ZijZ72EeBi4g8oBy8zAWuUR1TsMpOzwpUMFvjdasrC41Joug==", + "dev": true, + "license": "MIT", + "dependencies": { + "tabbable": "^6.5.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -6050,6 +7913,21 @@ "node": ">= 0.8" } }, + "node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -6092,6 +7970,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", @@ -6209,6 +8097,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -6265,6 +8174,44 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -6282,16 +8229,33 @@ "hermes-estree": "0.25.1" } }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, "node_modules/hono": { - "version": "4.12.33", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.33.tgz", - "integrity": "sha512-+SwvkaiJtxsiPjhy9LivY/1m7UsNqCJetM1BrZl9A5DkQhlbHQDU730mMiDPWjnoCYOM8Chf3WrCJw27kNTPFQ==", + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", + "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", "dev": true, "license": "MIT", "engines": { "node": ">=16.9.0" } }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "dev": true, + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -6299,6 +8263,17 @@ "dev": true, "license": "MIT" }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -6320,6 +8295,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/human-id": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/human-id/-/human-id-4.2.0.tgz", + "integrity": "sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==", + "dev": true, + "license": "MIT", + "bin": { + "human-id": "dist/cli.js" + } + }, "node_modules/human-signals": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz", @@ -6384,6 +8369,16 @@ "node": ">=4" } }, + "node_modules/import-lazy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", + "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -6438,6 +8433,22 @@ "dev": true, "license": "MIT" }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -6464,6 +8475,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -6588,6 +8609,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-subdir": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-subdir/-/is-subdir-1.2.0.tgz", + "integrity": "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "better-path-resolve": "1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/is-unicode-supported": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", @@ -6601,6 +8635,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -6673,6 +8717,13 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jju": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", + "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", + "dev": true, + "license": "MIT" + }, "node_modules/jose": { "version": "6.2.7", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.7.tgz", @@ -6780,6 +8831,16 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7123,6 +9184,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.startcase": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.startcase/-/lodash.startcase-4.4.0.tgz", + "integrity": "sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==", + "dev": true, + "license": "MIT" + }, "node_modules/log-symbols": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", @@ -7223,6 +9291,61 @@ "node": ">=10" } }, + "node_modules/mark.js": { + "version": "8.11.1", + "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", + "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/marked": { + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz", + "integrity": "sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/marked-terminal": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/marked-terminal/-/marked-terminal-7.3.0.tgz", + "integrity": "sha512-t4rBvPsHc57uE/2nJOLmMbZCQ4tgAccAED3ngXQqW6g+TxA488JzJ+FK3lQkzBQOI1mRV/r/Kq+1ZlJ4D0owQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "ansi-regex": "^6.1.0", + "chalk": "^5.4.1", + "cli-highlight": "^2.1.11", + "cli-table3": "^0.6.5", + "node-emoji": "^2.2.0", + "supports-hyperlinks": "^3.1.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "marked": ">=1 <16" + } + }, + "node_modules/marked-terminal/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7233,6 +9356,28 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/media-typer": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", @@ -7271,29 +9416,123 @@ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", "dev": true, + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT" }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" }, "node_modules/micromatch": { "version": "4.0.8", @@ -7398,6 +9637,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minisearch": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", + "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", + "dev": true, + "license": "MIT" + }, "node_modules/mlly": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", @@ -7445,6 +9691,16 @@ "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==", "license": "MIT" }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -7462,6 +9718,18 @@ "dev": true, "license": "MIT" }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -7508,6 +9776,22 @@ "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, + "node_modules/node-emoji": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", + "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.6.0", + "char-regex": "^1.0.2", + "emojilib": "^2.4.0", + "skin-tone": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/node-releases": { "version": "2.0.48", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", @@ -7644,6 +9928,25 @@ "node": ">=6" } }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "dev": true, + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, "node_modules/open": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", @@ -7736,6 +10039,26 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/outdent": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.5.0.tgz", + "integrity": "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/p-filter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-filter/-/p-filter-2.1.0.tgz", + "integrity": "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-map": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -7768,6 +10091,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-2.1.0.tgz", + "integrity": "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -7778,6 +10111,16 @@ "node": ">=6" } }, + "node_modules/package-manager-detector": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-0.2.11.tgz", + "integrity": "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "quansync": "^0.2.7" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7829,6 +10172,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", + "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", + "integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^6.0.1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true, + "license": "MIT" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -7866,6 +10233,13 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -7877,6 +10251,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -7884,6 +10268,13 @@ "dev": true, "license": "MIT" }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7904,6 +10295,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -8169,6 +10570,17 @@ "node": ">=6" } }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -8183,6 +10595,35 @@ "node": ">= 0.10" } }, + "node_modules/publint": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/publint/-/publint-0.3.23.tgz", + "integrity": "sha512-5MQipUPcB7MWw84zLUkHrg/H/UBtk3LL+A0GngTTBSsiNJLQurMUaSIRG3edlOrRz4UFe0AOKK9TZdIWviV+jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@publint/pack": "^0.1.6", + "package-manager-detector": "^1.7.0", + "picocolors": "^1.1.1", + "sade": "^1.8.1" + }, + "bin": { + "publint": "src/cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://bjornlu.com/sponsor" + } + }, + "node_modules/publint/node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "dev": true, + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -8470,6 +10911,46 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, + "node_modules/read-yaml-file": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz", + "integrity": "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.5", + "js-yaml": "^3.6.1", + "pify": "^4.0.1", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/read-yaml-file/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/read-yaml-file/node_modules/js-yaml": { + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/recast": { "version": "0.23.19", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.19.tgz", @@ -8487,6 +10968,43 @@ "node": ">= 4" } }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -8503,6 +11021,38 @@ "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", "license": "MIT" }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -8548,13 +11098,13 @@ } }, "node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.2.tgz", + "integrity": "sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.139.0", + "@oxc-project/types": "=0.142.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -8564,21 +11114,20 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" + "@rolldown/binding-android-arm64": "1.2.2", + "@rolldown/binding-darwin-arm64": "1.2.2", + "@rolldown/binding-darwin-x64": "1.2.2", + "@rolldown/binding-freebsd-x64": "1.2.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.2", + "@rolldown/binding-linux-arm64-gnu": "1.2.2", + "@rolldown/binding-linux-arm64-musl": "1.2.2", + "@rolldown/binding-linux-ppc64-gnu": "1.2.2", + "@rolldown/binding-linux-s390x-gnu": "1.2.2", + "@rolldown/binding-linux-x64-gnu": "1.2.2", + "@rolldown/binding-linux-x64-musl": "1.2.2", + "@rolldown/binding-openharmony-arm64": "1.2.2", + "@rolldown/binding-win32-arm64-msvc": "1.2.2", + "@rolldown/binding-win32-x64-msvc": "1.2.2" } }, "node_modules/router": { @@ -8635,6 +11184,19 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -8830,6 +11392,26 @@ "node": ">=8" } }, + "node_modules/shiki": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.1.tgz", + "integrity": "sha512-rFP+iYKzjLEIqiMiKANhARqiAbk4deDhWnBtnUO/K0D0dPxMGDH4N0FVfBY/VeI+lPrV4wNGCHQZp7EOr7NNBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.4.1", + "@shikijs/engine-javascript": "4.4.1", + "@shikijs/engine-oniguruma": "4.4.1", + "@shikijs/langs": "4.4.1", + "@shikijs/themes": "4.4.1", + "@shikijs/types": "4.4.1", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -8948,6 +11530,29 @@ "dev": true, "license": "MIT" }, + "node_modules/skin-tone": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", + "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-emoji-modifier-base": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -8978,6 +11583,35 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/spawndamnit": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spawndamnit/-/spawndamnit-3.0.1.tgz", + "integrity": "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==", + "dev": true, + "license": "SEE LICENSE IN LICENSE", + "dependencies": { + "cross-spawn": "^7.0.5", + "signal-exit": "^4.0.1" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -9015,6 +11649,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -9062,6 +11706,21 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/stringify-object": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-5.0.0.tgz", @@ -9142,6 +11801,36 @@ "node": ">=8" } }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/systeminformation": { "version": "5.33.1", "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.33.1.tgz", @@ -9169,6 +11858,13 @@ "url": "https://www.buymeacoffee.com/systeminfo" } }, + "node_modules/tabbable": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz", + "integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", @@ -9200,6 +11896,42 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/term-size": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", + "integrity": "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -9284,6 +12016,17 @@ "node": ">=6" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -9446,6 +12189,16 @@ "dev": true, "license": "MIT" }, + "node_modules/unicode-emoji-modifier-base": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unicode-emoji-modifier-base/-/unicode-emoji-modifier-base-1.0.0.tgz", + "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/unicorn-magic": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", @@ -9459,6 +12212,89 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -9665,17 +12501,47 @@ "node": ">= 0.8" } }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { - "version": "8.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", - "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", + "lightningcss": "^1.33.0", "picomatch": "^4.0.5", - "postcss": "^8.5.17", - "rolldown": "~1.1.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", "tinyglobby": "^0.2.17" }, "bin": { @@ -9692,7 +12558,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", + "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -9784,6 +12650,49 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/vitepress": { + "version": "2.0.0-alpha.19", + "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-2.0.0-alpha.19.tgz", + "integrity": "sha512-WnBsb0Bwr43kXKyiis+lld/7ri3hnMbthS8N3hpFtjjwsdLO4IRmiAE08D7aud4q6oMDf9uwRowxzNqRFe/amw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@docsearch/css": "^4.7.0", + "@docsearch/js": "^4.7.0", + "@docsearch/sidepanel-js": "^4.7.0", + "@iconify-json/simple-icons": "^1.2.92", + "@shikijs/core": "^4.4.1", + "@shikijs/transformers": "^4.4.1", + "@shikijs/types": "^4.4.1", + "@types/markdown-it": "^14.1.2", + "@vitejs/plugin-vue": "^6.0.8", + "@vue/devtools-api": "^8.2.1", + "@vue/shared": "^3.5.40", + "@vueuse/core": "^14.4.0", + "@vueuse/integrations": "^14.4.0", + "focus-trap": "^8.2.2", + "mark.js": "8.11.1", + "minisearch": "^7.2.0", + "shiki": "^4.4.1", + "vite": "^8.2.0", + "vue": "^3.5.40" + }, + "bin": { + "vitepress": "bin/vitepress.js" + }, + "peerDependencies": { + "markdown-it-mathjax3": "^4", + "postcss": "^8" + }, + "peerDependenciesMeta": { + "markdown-it-mathjax3": { + "optional": true + }, + "postcss": { + "optional": true + } + } + }, "node_modules/vitest": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", @@ -9881,6 +12790,28 @@ "dev": true, "license": "MIT" }, + "node_modules/vue": { + "version": "3.5.40", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz", + "integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.40", + "@vue/compiler-sfc": "3.5.40", + "@vue/runtime-dom": "3.5.40", + "@vue/server-renderer": "3.5.40", + "@vue/shared": "3.5.40" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", @@ -9931,6 +12862,46 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/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/wrap-ansi/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/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -9977,6 +12948,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -9984,6 +12965,57 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "16.2.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.2.tgz", + "integrity": "sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -10059,6 +13091,17 @@ "zod": "^3.25.0 || ^4.0.0" } }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "packages/core": { "name": "@/core", "version": "1.0.0", diff --git a/package.json b/package.json index ad94612..6dfd6d6 100644 --- a/package.json +++ b/package.json @@ -4,15 +4,21 @@ "version": "1.0.0", "type": "module", "packageManager": "npm@11.7.0", - "engines": { - "node": ">=24.0.0 <25" - }, "scripts": { "dev": "vite", "build": "tsc -p tsconfig.app.json --noEmit && vite build", - "build:lib": "vite build --config vite.lib.config.ts", + "build:lib": "vite build --config vite.lib.config.ts && api-extractor run", + "docs:dev": "vitepress dev docs", + "docs:build": "vitepress build docs", + "api:report": "api-extractor run --local", + "api:report:check": "api-extractor run", + "check:package": "npm run api:report:check && publint && attw --pack . --profile esm-only --exclude-entrypoints ./style.css ./index.css && npm run verify:npm-consumer", + "check:bundle-size": "node scripts/check-bundle-size.mjs", + "sbom": "npm sbom --sbom-format cyclonedx > sbom.cdx.json", + "changeset": "changeset", + "version-packages": "changeset version", "verify:npm-consumer": "node scripts/verify-npm-consumer.mjs", - "lint": "eslint .", + "lint": "eslint . --max-warnings 0", "format": "prettier --write .", "format:check": "prettier --check \"*.{md,json}\" \"docs/**/*.md\" \".github/**/*.{yml,yaml,md}\" \"src/**/*.{ts,tsx,js,mjs,css,json,md}\" \"vite/**/*.{ts,tsx}\" \"scripts/**/*.{ts,tsx,js,mjs,json}\" \"tests/**/*.{ts,tsx,js,mjs,json}\"", "fixtures:generate": "node scripts/generate-test-fixtures.mjs", @@ -20,12 +26,16 @@ "test": "vitest run", "test:unit": "vitest run", "test:browser": "vitest run --config vitest.browser.config.ts", + "test:browser:smoke": "vitest run --config vitest.browser.smoke.config.ts", "test:compat": "vitest run tests/compat", "test:coverage": "vitest run --coverage", "preview": "vite preview" }, "devDependencies": { + "@arethetypeswrong/cli": "^0.18.5", + "@changesets/cli": "^2.31.1", "@eslint/js": "^10.0.1", + "@microsoft/api-extractor": "^7.58.12", "@playwright/test": "^1.62.0", "@tailwindcss/vite": "^4.3.3", "@types/node": "^26.1.2", @@ -35,6 +45,7 @@ "@vitest/browser": "^4.1.10", "@vitest/browser-playwright": "^4.1.10", "@vitest/coverage-v8": "^4.1.10", + "axe-core": "^4.12.1", "eslint": "^10.8.0", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", @@ -42,24 +53,26 @@ "lightningcss": "^1.33.0", "playwright": "^1.62.0", "prettier": "^3.6.2", + "publint": "^0.3.23", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "shadcn": "^4.16.0", "tailwindcss": "^4.3.3", "typescript": "6.0.3", "typescript-eslint": "^8.65.0", "vite": "^8.1.5", "vite-plugin-dts": "^5.0.3", - "vitest": "^4.1.10", - "react": "^19.2.8", - "react-dom": "^19.2.8", - "shadcn": "^4.16.0" + "vitepress": "2.0.0-alpha.19", + "vitest": "^4.1.10" }, "description": "Browser-based LeRobot dataset visualizer and React embeddable studio", "license": "MIT", "main": "./dist-lib/lerobot.es.js", "module": "./dist-lib/lerobot.es.js", - "types": "./dist-lib/react/index.d.ts", + "types": "./dist-lib/lerobot.d.ts", "exports": { ".": { - "types": "./dist-lib/react/index.d.ts", + "types": "./dist-lib/lerobot.d.ts", "import": "./dist-lib/lerobot.es.js", "default": "./dist-lib/lerobot.es.js" }, @@ -67,11 +80,14 @@ "./index.css": "./dist-lib/lerobot-studio.css" }, "files": [ - "dist-lib" + "dist-lib", + "CHANGELOG.md", + "LICENSE", + "NOTICE", + "README.md" ], "sideEffects": [ - "**/*.css", - "./src/platform/workers/**" + "**/*.css" ], "repository": { "type": "git", @@ -82,8 +98,8 @@ }, "homepage": "https://github.com/ioai-tech/lerobot-studio#readme", "peerDependencies": { - "react": "^19.2.8", - "react-dom": "^19.2.8" + "react": "^19.0.0", + "react-dom": "^19.0.0" }, "publishConfig": { "access": "public" diff --git a/scripts/check-bundle-size.mjs b/scripts/check-bundle-size.mjs new file mode 100644 index 0000000..b339a2c --- /dev/null +++ b/scripts/check-bundle-size.mjs @@ -0,0 +1,283 @@ +import { spawnSync } from 'node:child_process'; +import { readdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { gzipSync } from 'node:zlib'; + +const ROOT = process.cwd(); +const LIB_DIR = path.join(ROOT, 'dist-lib'); +const WEB_DIR = path.join(ROOT, 'dist'); +const scale = Number(process.env.BUNDLE_SIZE_BUDGET_SCALE ?? '1'); + +if (!Number.isFinite(scale) || scale <= 0 || scale > 1) { + console.error('BUNDLE_SIZE_BUDGET_SCALE must be greater than 0 and at most 1.'); + process.exit(2); +} + +// These hard limits are approximately 5% above the measured 2026-08-04 baseline. +// Raising one requires an explicit review of the generated artifacts. +const budgets = { + libEntry: { raw: 330_300, gzip: 107_300 }, + libCss: { raw: 354_200, gzip: 108_500 }, + webInitialJs: { raw: 2_664_000, gzip: 715_000 }, + webInitialCss: { raw: 192_000, gzip: 24_400 }, + libDataLoader: { raw: 272_100, gzip: 56_200 }, + webDataLoader: { raw: 33_500, gzip: 9_300 }, + libDockview: { raw: 670_900, gzip: 158_700 }, + webDockview: { raw: 91_500, gzip: 26_900 }, + libWorkerWasm: { raw: 9_100_000, gzip: 2_940_000 }, + libWorkers: { raw: 461_000, gzip: 106_000 }, + webWorkerWasm: { raw: 6_819_000, gzip: 2_014_000 }, + webWorkers: { raw: 461_000, gzip: 106_000 }, + largestLibJs: { raw: 670_900, gzip: 158_700 }, + largestWebJs: { raw: 1_108_200, gzip: 313_400 }, + npmTarball: { raw: 11_554_000, gzip: 3_444_000 }, +}; + +const failures = []; + +async function walkFiles(directory) { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + failures.push(`Missing build output directory: ${path.relative(ROOT, directory)}/`); + return []; + } + + const files = []; + for (const entry of entries) { + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...(await walkFiles(absolute))); + } else if (entry.isFile()) { + files.push(absolute); + } + } + return files; +} + +function oneFile(files, pattern, description) { + const matches = files.filter((file) => pattern.test(path.basename(file))); + if (matches.length !== 1) { + failures.push( + `Expected exactly one ${description}; found ${matches.length}: ${ + matches.map((file) => path.relative(ROOT, file)).join(', ') || 'none' + }`, + ); + } + return matches; +} + +async function measureFiles(files) { + let raw = 0; + let gzip = 0; + for (const file of files) { + try { + const contents = await readFile(file); + raw += contents.byteLength; + gzip += gzipSync(contents, { level: 9 }).byteLength; + } catch (error) { + failures.push(`Cannot read artifact ${path.relative(ROOT, file)}: ${error.message}`); + } + } + return { raw, gzip }; +} + +function budgetFor(key) { + return { + raw: Math.floor(budgets[key].raw * scale), + gzip: Math.floor(budgets[key].gzip * scale), + }; +} + +function formatBytes(bytes) { + if (!Number.isFinite(bytes)) return '-'; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KiB`; + return `${(bytes / 1024 ** 2).toFixed(2)} MiB`; +} + +const rows = []; + +async function checkGroup(label, key, files) { + const actual = await measureFiles(files); + const limit = budgetFor(key); + rows.push({ label, actual, limit }); + for (const encoding of ['raw', 'gzip']) { + if (actual[encoding] > limit[encoding]) { + failures.push( + `${label} ${encoding} is ${formatBytes(actual[encoding])}; limit is ${formatBytes(limit[encoding])}.`, + ); + } + } +} + +async function checkLargestChunk(label, key, files) { + const measured = await Promise.all( + files.map(async (file) => ({ file, ...(await measureFiles([file])) })), + ); + const largestRaw = measured.toSorted((a, b) => b.raw - a.raw)[0] ?? { raw: 0, gzip: 0 }; + const largestGzip = measured.toSorted((a, b) => b.gzip - a.gzip)[0] ?? { + raw: 0, + gzip: 0, + }; + const actual = { raw: largestRaw.raw, gzip: largestGzip.gzip }; + const limit = budgetFor(key); + rows.push({ label, actual, limit }); + + for (const item of measured) { + if (item.raw > limit.raw || item.gzip > limit.gzip) { + failures.push( + `Unexpected huge chunk ${path.relative(ROOT, item.file)} is ${formatBytes(item.raw)} raw / ${formatBytes(item.gzip)} gzip; per-chunk limit is ${formatBytes(limit.raw)} raw / ${formatBytes(limit.gzip)} gzip.`, + ); + } + } +} + +function htmlAssetPaths(html, relation) { + const matches = []; + const tagPattern = relation === 'script' ? /]*>/gi : /]*>/gi; + for (const tag of html.match(tagPattern) ?? []) { + const rel = tag.match(/\brel=["']([^"']+)["']/i)?.[1]; + const attribute = relation === 'script' ? 'src' : 'href'; + if ( + relation !== 'script' && + !rel + ?.split(/\s+/) + .map((value) => value.toLowerCase()) + .includes(relation) + ) { + continue; + } + const value = tag.match(new RegExp(`\\b${attribute}=["']([^"']+)["']`, 'i'))?.[1]; + if (value) matches.push(value); + } + return matches; +} + +function resolveWebAssets(references, description) { + if (references.length === 0) + failures.push(`No ${description} references found in dist/index.html.`); + return references.map((reference) => path.join(WEB_DIR, reference.replace(/^\/+/, ''))); +} + +function readPackMetadata() { + const result = spawnSync('npm', ['pack', '--dry-run', '--json'], { + cwd: ROOT, + encoding: 'utf8', + }); + if (result.status !== 0) { + failures.push(`npm pack --dry-run failed:\n${result.stderr || result.stdout}`); + return null; + } + try { + const metadata = JSON.parse(result.stdout); + if (!Array.isArray(metadata) || metadata.length !== 1) throw new Error('unexpected result'); + return metadata[0]; + } catch (error) { + failures.push(`Could not parse npm pack metadata: ${error.message}`); + return null; + } +} + +const [libFiles, webFiles] = await Promise.all([walkFiles(LIB_DIR), walkFiles(WEB_DIR)]); +const libEntry = oneFile(libFiles, /^lerobot\.es\.js$/, 'stable library entry'); +const libCss = oneFile(libFiles, /^lerobot-studio\.css$/, 'library CSS artifact'); +const libDataLoader = oneFile( + libFiles, + /^LeRobotDataLoader-[\w-]+\.js$/, + 'library DataLoader chunk', +); +const libDockview = oneFile(libFiles, /^DockviewLayout-[\w-]+\.js$/, 'library Dockview chunk'); +const libWorkers = libFiles.filter((file) => /\.worker-[\w-]+\.js$/i.test(path.basename(file))); +const libWorkerWasm = libFiles.filter( + (file) => /wasm/i.test(path.basename(file)) && !libWorkers.includes(file), +); + +if (libWorkerWasm.length === 0) failures.push('No library Worker/WASM artifacts found.'); +if (libWorkers.length !== 2) + failures.push(`Expected two library Workers; found ${libWorkers.length}.`); + +let webInitialJs = []; +let webInitialCss = []; +try { + const html = await readFile(path.join(WEB_DIR, 'index.html'), 'utf8'); + webInitialJs = resolveWebAssets( + [...htmlAssetPaths(html, 'script'), ...htmlAssetPaths(html, 'modulepreload')], + 'initial JS', + ); + webInitialCss = resolveWebAssets(htmlAssetPaths(html, 'stylesheet'), 'initial CSS'); +} catch (error) { + failures.push(`Missing or unreadable dist/index.html: ${error.message}`); +} + +const webDataLoader = oneFile(webFiles, /^LeRobotDataLoader-[\w-]+\.js$/, 'web DataLoader chunk'); +const webDockview = oneFile(webFiles, /^DockviewLayout-[\w-]+\.js$/, 'web Dockview chunk'); +const webWorkers = webFiles.filter((file) => /\.worker-[\w-]+\.js$/i.test(path.basename(file))); +const webWorkerWasm = webFiles.filter( + (file) => /wasm/i.test(path.basename(file)) && !webWorkers.includes(file), +); + +if (webWorkerWasm.length === 0) failures.push('No web Worker/WASM artifacts found.'); +if (webWorkers.length !== 2) failures.push(`Expected two web Workers; found ${webWorkers.length}.`); + +await checkGroup('Library stable entry', 'libEntry', libEntry); +await checkGroup('Library CSS', 'libCss', libCss); +await checkGroup('Web initial synchronous JS', 'webInitialJs', webInitialJs); +await checkGroup('Web initial CSS', 'webInitialCss', webInitialCss); +await checkGroup('Library lazy DataLoader', 'libDataLoader', libDataLoader); +await checkGroup('Web lazy DataLoader', 'webDataLoader', webDataLoader); +await checkGroup('Library lazy Dockview', 'libDockview', libDockview); +await checkGroup('Web lazy Dockview', 'webDockview', webDockview); +await checkGroup('Library Worker/WASM', 'libWorkerWasm', libWorkerWasm); +await checkGroup('Library external Workers', 'libWorkers', libWorkers); +await checkGroup('Web Worker/WASM', 'webWorkerWasm', webWorkerWasm); +await checkGroup('Web external Workers', 'webWorkers', webWorkers); +await checkLargestChunk( + 'Largest library JS chunk', + 'largestLibJs', + libFiles.filter((file) => file.endsWith('.js') && !libWorkerWasm.includes(file)), +); +await checkLargestChunk( + 'Largest web JS chunk', + 'largestWebJs', + webFiles.filter((file) => file.endsWith('.js') && !webWorkerWasm.includes(file)), +); + +const pack = readPackMetadata(); +if (pack) { + const actual = { raw: pack.unpackedSize, gzip: pack.size }; + const limit = budgetFor('npmTarball'); + rows.push({ label: 'npm tarball (unpacked / .tgz)', actual, limit }); + if (actual.raw > limit.raw) { + failures.push( + `npm tarball unpacked size is ${formatBytes(actual.raw)}; limit is ${formatBytes(limit.raw)}.`, + ); + } + if (actual.gzip > limit.gzip) { + failures.push( + `npm tarball .tgz size is ${formatBytes(actual.gzip)}; limit is ${formatBytes(limit.gzip)}.`, + ); + } +} + +const labelWidth = Math.max(...rows.map((row) => row.label.length), 24); +console.log('\nBundle size report (raw and gzip):'); +console.log( + `${'Artifact'.padEnd(labelWidth)} ${'Raw / limit'.padEnd(25)} ${'Gzip / limit'.padEnd(25)}`, +); +for (const row of rows) { + const raw = `${formatBytes(row.actual.raw)} / ${formatBytes(row.limit.raw)}`; + const gzip = `${formatBytes(row.actual.gzip)} / ${formatBytes(row.limit.gzip)}`; + console.log(`${row.label.padEnd(labelWidth)} ${raw.padEnd(25)} ${gzip.padEnd(25)}`); +} + +console.log('\nNote: library Workers are external and share one lazy package-local WASM payload.'); + +if (failures.length > 0) { + console.error(`\nBundle size gate failed with ${failures.length} problem(s):`); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log('\nBundle size gate passed.'); diff --git a/scripts/generate-test-fixtures.mjs b/scripts/generate-test-fixtures.mjs index 3721b6c..d6e7c0e 100644 --- a/scripts/generate-test-fixtures.mjs +++ b/scripts/generate-test-fixtures.mjs @@ -11,7 +11,16 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; -import { tableToIPC, vectorFromArray, Binary, Table } from 'apache-arrow'; +import { + tableToIPC, + vectorFromArray, + Binary, + Field, + Float64, + Int64, + List, + Table, +} from 'apache-arrow'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const outRoot = path.join(root, 'tests/fixtures/datasets'); @@ -22,6 +31,34 @@ const FPS = 10; const FRAMES_PER_EP = 3; const EPISODE_DURATION_SEC = FRAMES_PER_EP / FPS; +function solidVisualEpisodeStats(rgb, count = FRAMES_PER_EP) { + const shaped = rgb.map((value) => [[value]]); + return { + [`stats/${VIDEO_KEY}/min`]: shaped, + [`stats/${VIDEO_KEY}/max`]: shaped, + [`stats/${VIDEO_KEY}/mean`]: shaped, + [`stats/${VIDEO_KEY}/std`]: rgb.map(() => [[0]]), + [`stats/${VIDEO_KEY}/q01`]: shaped, + [`stats/${VIDEO_KEY}/q10`]: shaped, + [`stats/${VIDEO_KEY}/q50`]: shaped, + [`stats/${VIDEO_KEY}/q90`]: shaped, + [`stats/${VIDEO_KEY}/q99`]: shaped, + [`stats/${VIDEO_KEY}/count`]: [count], + }; +} + +function solidVisualStatsObject(rgb, count = FRAMES_PER_EP) { + const prefix = `stats/${VIDEO_KEY}/`; + return { + [VIDEO_KEY]: Object.fromEntries( + Object.entries(solidVisualEpisodeStats(rgb, count)).map(([key, value]) => [ + key.slice(prefix.length), + value, + ]), + ), + }; +} + /** Minimal 64×64 PNG — Chromium's AVC encoder rejects tiny frames (e.g. 2×2). */ const TINY_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAeElEQVR4nO3PQQkAMAzAwIqdfwuriD2OQSACLjPn/p0XNKAFDWhBA1rQgBY0oAUNaEEDWtCAFjSgBQ1oQQNa0IAWNKAFDWhBA1rQgBY0oAUNaEEDWtCAFjSgBQ1oQQNa0IAWNKAFDWhBA1rQgBY0oAUNaEEDWvDWAp47MUuty3rOAAAAAElFTkSuQmCC', @@ -68,12 +105,45 @@ async function writeParquetFromColumns(filePath, columns) { Array.isArray(values) && values.length > 0 && values.every((v) => v instanceof Uint8Array || v instanceof Buffer); - vectors[name] = isBinary - ? vectorFromArray( - values.map((v) => (v instanceof Uint8Array ? v : new Uint8Array(v))), - new Binary(), - ) - : vectorFromArray(values); + if (isBinary) { + vectors[name] = vectorFromArray( + values.map((v) => (v instanceof Uint8Array ? v : new Uint8Array(v))), + new Binary(), + ); + continue; + } + const sample = values.find((value) => value != null); + if (Array.isArray(sample)) { + const leaves = []; + const collect = (value) => + Array.isArray(value) + ? value.forEach(collect) + : value == null + ? undefined + : leaves.push(value); + values.forEach(collect); + const integers = leaves.every( + (value) => typeof value === 'number' && Number.isInteger(value), + ); + let type = integers ? new Int64() : new Float64(); + let current = sample; + while (Array.isArray(current)) { + type = new List(new Field('item', type, true)); + current = current[0]; + } + const normalized = integers + ? values.map(function normalize(value) { + return Array.isArray(value) + ? value.map(normalize) + : value == null + ? null + : BigInt(value); + }) + : values; + vectors[name] = vectorFromArray(normalized, type); + continue; + } + vectors[name] = vectorFromArray(values); } const table = new Table(vectors); const ipc = tableToIPC(table, 'stream'); @@ -168,6 +238,17 @@ async function createV2(mp4Bytes) { JSON.stringify({ episode_index: 1, length: FRAMES_PER_EP, tasks: ['pick cube'] }), ].join('\n') + '\n', ); + await writeText( + path.join(base, 'meta/episodes_stats.jsonl'), + [0, 1] + .map((episode_index) => + JSON.stringify({ + episode_index, + stats: solidVisualStatsObject([0, 0, 1]), + }), + ) + .join('\n') + '\n', + ); await writeText( path.join(base, 'meta/tasks.jsonl'), `${JSON.stringify({ task_index: 0, task: 'pick cube' })}\n`, @@ -222,6 +303,12 @@ async function createV3(mp4Bytes) { [`videos/${VIDEO_KEY}/file_index`]: [0, 1], [`videos/${VIDEO_KEY}/from_timestamp`]: [0, 0], [`videos/${VIDEO_KEY}/to_timestamp`]: [EPISODE_DURATION_SEC, EPISODE_DURATION_SEC], + ...Object.fromEntries( + Object.entries(solidVisualEpisodeStats([0, 0, 1])).map(([key, value]) => [ + key, + [value, value], + ]), + ), }); await writeParquetFromColumns(path.join(base, 'meta/tasks.parquet'), { @@ -277,6 +364,17 @@ async function createV2Image() { JSON.stringify({ episode_index: 1, length: FRAMES_PER_EP, tasks: ['pick cube'] }), ].join('\n') + '\n', ); + await writeText( + path.join(base, 'meta/episodes_stats.jsonl'), + [0, 1] + .map((episode_index) => + JSON.stringify({ + episode_index, + stats: solidVisualStatsObject([0, 100 / 255, 1]), + }), + ) + .join('\n') + '\n', + ); await writeText( path.join(base, 'meta/tasks.jsonl'), `${JSON.stringify({ task_index: 0, task: 'pick cube' })}\n`, diff --git a/scripts/verify-npm-consumer.mjs b/scripts/verify-npm-consumer.mjs index 48d07ef..0216fec 100644 --- a/scripts/verify-npm-consumer.mjs +++ b/scripts/verify-npm-consumer.mjs @@ -1,41 +1,192 @@ import { execFile } from 'node:child_process'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { cp, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { promisify } from 'node:util'; +import { chromium } from 'playwright'; const execFileAsync = promisify(execFile); const root = path.resolve(import.meta.dirname, '..'); const packageDir = root; const tempDir = await mkdtemp(path.join(os.tmpdir(), 'lerobot-studio-consumer-')); +const runNext = process.env.VERIFY_NEXT_EXAMPLE !== '0'; + +async function run(command, args, options = {}) { + try { + return await execFileAsync(command, args, { + maxBuffer: 20 * 1024 * 1024, + ...options, + }); + } catch (error) { + const details = [error.stdout, error.stderr].filter(Boolean).join('\n'); + throw new Error(`${command} ${args.join(' ')} failed\n${details}`, { cause: error }); + } +} + +async function collectFiles(directory) { + const results = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + const filePath = path.join(directory, entry.name); + if (entry.isDirectory()) results.push(...(await collectFiles(filePath))); + else results.push(filePath); + } + return results; +} + +function runtimeExportsFromApiReport(report) { + return Array.from( + report.matchAll(/^export (?:abstract )?(?:class|const|enum|function|namespace|var) ([\w$]+)/gm), + (match) => match[1], + ).sort(); +} + +async function serve(directory, mountPath = '/') { + const contentTypes = { + '.css': 'text/css', + '.html': 'text/html', + '.js': 'text/javascript', + '.map': 'application/json', + '.svg': 'image/svg+xml', + '.wasm': 'application/wasm', + }; + const server = createServer(async (request, response) => { + try { + const urlPath = decodeURIComponent(new URL(request.url ?? '/', 'http://localhost').pathname); + const normalizedMount = `/${mountPath.replace(/^\/+|\/+$/g, '')}/`; + if (!urlPath.startsWith(normalizedMount)) throw new Error('Outside mount path'); + const mountedPath = urlPath.slice(normalizedMount.length); + const relativePath = mountedPath === '' ? 'index.html' : mountedPath; + const filePath = path.resolve(directory, relativePath); + if (!filePath.startsWith(`${path.resolve(directory)}${path.sep}`)) { + response.writeHead(403).end('Forbidden'); + return; + } + if (!(await stat(filePath)).isFile()) throw new Error('Not a file'); + response.setHeader( + 'content-type', + contentTypes[path.extname(filePath)] ?? 'application/octet-stream', + ); + response.end(await readFile(filePath)); + } catch { + response.writeHead(404).end('Not found'); + } + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Failed to start consumer server'); + return { + url: `http://127.0.0.1:${address.port}`, + close: () => + new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ), + }; +} try { - const { stdout } = await execFileAsync('npm', ['pack', '--json', '--pack-destination', tempDir], { + const { stdout } = await run('npm', ['pack', '--json', '--pack-destination', tempDir], { cwd: packageDir, }); - const [{ filename }] = JSON.parse(stdout); + const [{ filename, files }] = JSON.parse(stdout); const tarball = path.join(tempDir, filename); + const packedPaths = new Set(files.map((file) => file.path)); + const requiredPaths = [ + 'package.json', + 'README.md', + 'LICENSE', + 'NOTICE', + 'CHANGELOG.md', + 'dist-lib/lerobot.es.js', + 'dist-lib/lerobot-studio.css', + 'dist-lib/lerobot.d.ts', + ]; + + for (const requiredPath of requiredPaths) { + if (!packedPaths.has(requiredPath)) { + throw new Error(`npm package is missing required file: ${requiredPath}`); + } + } + const workerPaths = files.filter((file) => + /^dist-lib\/assets\/.*\.worker-.*\.js$/.test(file.path), + ); + if (workerPaths.length !== 2) { + throw new Error( + `npm package must contain two external Worker assets; found ${workerPaths.length}`, + ); + } + const lazyWasmPaths = files.filter((file) => /^dist-lib\/wasmUrl-.*\.js$/.test(file.path)); + if (lazyWasmPaths.length !== 1) { + throw new Error(`npm package must contain one lazy WASM chunk; found ${lazyWasmPaths.length}`); + } + + const forbiddenPath = files.find( + (file) => + file.path.startsWith('src/') || + file.path.startsWith('tests/') || + file.path.startsWith('.github/'), + ); + if (forbiddenPath) { + throw new Error(`npm package contains repository-only file: ${forbiddenPath.path}`); + } + + const packedJavaScript = ( + await Promise.all( + files + .filter((file) => file.path.endsWith('.js')) + .map((file) => readFile(path.join(packageDir, file.path), 'utf8')), + ) + ).join('\n'); + if ( + /new Worker\s*\(\s*["'`]\/assets\//i.test(packedJavaScript) || + /["'`]\/assets\/[^"'`]*(?:worker|wasm)[^"'`]*["'`]/i.test(packedJavaScript) + ) { + throw new Error('npm package contains an absolute /assets worker or WASM URL'); + } + const packedWorkerJavaScript = ( + await Promise.all( + files + .filter((file) => /worker/i.test(file.path) && file.path.endsWith('.js')) + .map((file) => readFile(path.join(packageDir, file.path), 'utf8')), + ) + ).join('\n'); + if (/data:application\/wasm/i.test(packedWorkerJavaScript)) { + throw new Error('npm package Worker artifacts contain an inline WASM payload'); + } await writeFile( path.join(tempDir, 'package.json'), JSON.stringify({ private: true, type: 'module' }, null, 2), ); - await execFileAsync( - 'npm', - ['install', '--ignore-scripts', tarball, 'typescript', '@types/react', '@types/react-dom'], - { cwd: tempDir, maxBuffer: 10 * 1024 * 1024 }, - ); + const installPackages = [ + tarball, + 'typescript', + 'react@^19', + 'react-dom@^19', + '@types/react@^19', + '@types/react-dom@^19', + 'vite', + '@vitejs/plugin-react', + ]; + if (runNext) installPackages.push('next'); + await run('npm', ['install', '--ignore-scripts', '--no-audit', '--no-fund', ...installPackages], { + cwd: tempDir, + }); await writeFile( path.join(tempDir, 'index.ts'), [ - "import { DirectoryDataSource, LeRobotStudioProvider } from '@ioai/lerobot-studio';", - "import type { DataSource, EpisodeMetadata } from '@ioai/lerobot-studio';", + "import { LeRobotViewer, createArchiveDataSourceFromFile, createArchiveDataSourceFromUrl } from '@ioai/lerobot-studio';", + "import type { DataSource, LeRobotViewerProps } from '@ioai/lerobot-studio';", 'const source: DataSource | null = null;', - 'const episodes: EpisodeMetadata[] = [];', + "const props: LeRobotViewerProps = { dataSource: 'https://example.com/dataset.zip' };", 'void source;', - 'void episodes;', - 'void DirectoryDataSource;', - 'void LeRobotStudioProvider;', + 'void props;', + 'void LeRobotViewer;', + 'void createArchiveDataSourceFromFile;', + 'void createArchiveDataSourceFromUrl;', '', ].join('\n'), ); @@ -57,8 +208,135 @@ try { 2, ), ); - await execFileAsync('npx', ['tsc', '--project', 'tsconfig.json'], { cwd: tempDir }); - console.log('npm consumer typecheck passed'); + await run(path.join(tempDir, 'node_modules/.bin/tsc'), ['--project', 'tsconfig.json'], { + cwd: tempDir, + }); + const apiReport = await readFile(path.join(root, 'etc/lerobot-studio.api.md'), 'utf8'); + const expectedRuntimeExports = runtimeExportsFromApiReport(apiReport); + await writeFile( + path.join(tempDir, 'runtime.mjs'), + [ + "const module = await import('@ioai/lerobot-studio');", + `const expected = ${JSON.stringify(expectedRuntimeExports)};`, + 'const actual = Object.keys(module).sort();', + 'if (JSON.stringify(actual) !== JSON.stringify(expected)) {', + " throw new Error(`Runtime exports (${actual.join(', ')}) differ from API report values (${expected.join(', ')})`);", + '}', + '', + ].join('\n'), + ); + await run(process.execPath, ['runtime.mjs'], { cwd: tempDir }); + + const viteExample = path.join(tempDir, 'vite-react'); + await cp(path.join(root, 'examples/vite-react'), viteExample, { recursive: true }); + await run(path.join(tempDir, 'node_modules/.bin/tsc'), ['--project', 'tsconfig.json'], { + cwd: viteExample, + }); + await run(path.join(tempDir, 'node_modules/.bin/vite'), ['build'], { cwd: viteExample }); + + const builtAssets = await collectFiles(path.join(viteExample, 'dist')); + const builtJavaScript = ( + await Promise.all( + builtAssets.filter((file) => file.endsWith('.js')).map((file) => readFile(file, 'utf8')), + ) + ).join('\n'); + if ( + /new Worker\s*\(\s*["'`]\/assets\//i.test(builtJavaScript) || + /["'`]\/assets\/[^"'`]*(?:worker|wasm)[^"'`]*["'`]/i.test(builtJavaScript) + ) { + throw new Error('Vite consumer build regressed to an absolute /assets worker or WASM URL'); + } + + const consumerMount = '/arbitrary/root/npm-consumer/'; + const staticServer = await serve(path.join(viteExample, 'dist'), consumerMount); + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + const badResponses = []; + const requestFailures = []; + const requestedResources = []; + const pageErrors = []; + page.on('response', (response) => { + requestedResources.push(`${response.status()} ${response.url()}`); + if (response.status() === 404) badResponses.push(response.url()); + }); + page.on('requestfailed', (request) => { + requestFailures.push(`${request.url()}: ${request.failure()?.errorText ?? 'failed'}`); + }); + page.on('pageerror', (error) => pageErrors.push(error.message)); + await page.goto(`${staticServer.url}${consumerMount}`, { waitUntil: 'networkidle' }); + await page.locator('[data-npm-consumer-mounted="true"]').waitFor(); + try { + await page.waitForFunction( + () => + window.__LEROBOT_CONSUMER__?.infoRead === true && + window.__LEROBOT_CONSUMER__?.wasmFetched === true, + ); + } catch (error) { + const marker = await page.evaluate(() => window.__LEROBOT_CONSUMER__); + throw new Error( + `Chromium Worker/WASM readiness timed out: ${JSON.stringify({ + marker, + badResponses, + requestFailures, + pageErrors, + requestedResources, + builtAssets: builtAssets.map((file) => + path.relative(path.join(viteExample, 'dist'), file), + ), + })}`, + { cause: error }, + ); + } + const browserResult = await page.evaluate(() => { + const root = document.querySelector('.lerobot-root'); + const cssLoaded = Array.from(document.styleSheets).some((sheet) => { + try { + return (sheet.cssRules?.length ?? 0) > 0; + } catch { + return false; + } + }); + return { + cssLoaded, + hasViewerRoot: Boolean(root), + marker: window.__LEROBOT_CONSUMER__, + }; + }); + if (!browserResult.hasViewerRoot) throw new Error('Chromium did not mount LeRobotViewer'); + if (!browserResult.cssLoaded) throw new Error('Chromium loaded no consumer CSS rules'); + if (!browserResult.marker?.workerConstructed) { + throw new Error('Chromium did not construct the library Worker'); + } + if (!browserResult.marker?.wasmFetched) { + throw new Error('Chromium did not fetch the library WASM asset'); + } + if (badResponses.length || requestFailures.length) { + throw new Error( + `Chromium resource failures: ${[...badResponses, ...requestFailures].join(', ')}`, + ); + } + } finally { + await browser.close(); + await staticServer.close(); + } + + if (runNext) { + const nextExample = path.join(tempDir, 'next-app-router'); + await cp(path.join(root, 'examples/next-app-router'), nextExample, { recursive: true }); + await run(process.execPath, [path.join(tempDir, 'node_modules/next/dist/bin/next'), 'build'], { + cwd: nextExample, + env: { ...process.env, NEXT_TELEMETRY_DISABLED: '1' }, + }); + } else { + console.warn('Next App Router build skipped because VERIFY_NEXT_EXAMPLE=0'); + } + + console.log( + `npm tarball passed package, API, type, Vite build, Chromium, CSS, Worker/WASM, and ${ + runNext ? 'Next build' : 'Next static-only' + } checks (${files.length} packed files)`, + ); } finally { await rm(tempDir, { recursive: true, force: true }); } diff --git a/src/core/analysis/SplitService.ts b/src/core/analysis/SplitService.ts index f593536..d97d5e6 100644 --- a/src/core/analysis/SplitService.ts +++ b/src/core/analysis/SplitService.ts @@ -25,13 +25,28 @@ export interface SplitConfig { /** Map split name -> episode indices (0-based in export order). */ export type SplitsIndices = Record; -/** Serialize splits to LeRobot v3 info.splits format: each value is comma-separated indices. */ +/** + * Serialize splits to the official LeRobot v3 half-open `start:end` format. + * A split must be one non-empty contiguous range in export order. + */ export function splitsIndicesToInfoSplits(splits: SplitsIndices): Record { const out: Record = {}; for (const [name, indices] of Object.entries(splits)) { - if (indices.length > 0) { - out[name] = indices.join(','); + if (indices.length === 0) continue; + for (let position = 0; position < indices.length; position++) { + const index = indices[position]; + if (!Number.isSafeInteger(index) || index < 0) { + throw new Error( + `Cannot export split "${name}": episode index ${String(index)} is not a non-negative safe integer.`, + ); + } + if (position > 0 && index !== indices[position - 1] + 1) { + throw new Error( + `Cannot export split "${name}" in LeRobot v3 start:end format: episode indices are not contiguous in export order. Reorder episodes by split or use range splits.`, + ); + } } + out[name] = `${indices[0]}:${indices[indices.length - 1] + 1}`; } return out; } diff --git a/src/core/analysis/StatsService.ts b/src/core/analysis/StatsService.ts index 83de02b..060e3b7 100644 --- a/src/core/analysis/StatsService.ts +++ b/src/core/analysis/StatsService.ts @@ -1,174 +1,508 @@ import type { Table } from 'apache-arrow'; -import type { LeRobotInfo, EpisodeMetadata, LeRobotFeature } from '../types/lerobot'; +import type { EpisodeMetadata, LeRobotFeature, LeRobotInfo } from '../types/lerobot'; -/** Duck-typed loader surface used for export/stats aggregation. */ export interface StatsDataLoader { getEpisodeTableForExport(episodeIndex: number): Promise<{ table: Table }>; } -/** Per-feature stats: arrays have one value per dimension (or length 1 for scalar). */ +/** Recursive JSON array; visual stats have shape [channels][1][1]. */ +export type StatsArray = Array; + export interface FeatureStats { - min: number[]; - max: number[]; - mean: number[]; - std: number[]; - count: number; + min: StatsArray; + max: StatsArray; + mean: StatsArray; + std: StatsArray; + q01: StatsArray; + q10: StatsArray; + q50: StatsArray; + q90: StatsArray; + q99: StatsArray; + count: number[]; } export type DatasetStats = Record; +export type NumericFeatureRow = number | readonly number[]; -function isNumericDtype(dtype: string | undefined): boolean { - if (!dtype) return false; - const d = dtype.toLowerCase(); - return d.includes('float') || d.includes('int') || d === 'int64' || d === 'float32'; +export interface NumericStatsRowContext { + episode: EpisodeMetadata; + outputEpisodeIndex: number; + rowIndex: number; + outputGlobalIndex: number; + sourceValues: readonly number[]; } -/** Extract numeric feature keys from info that exist in the table schema. */ -function getNumericFeatureKeys(info: LeRobotInfo, table: Table): string[] { - const keys: string[] = []; - for (const [key, feature] of Object.entries(info.features || {})) { - if (!feature || !isNumericDtype(feature.dtype)) continue; - if (!table.schema.fields.some((f) => f.name === key)) continue; - keys.push(key); - } - return keys; +export interface ComputeDatasetStatsOptions { + onProgress?: (current: number, total: number) => void; + signal?: AbortSignal; + /** + * Return the final exported value for a numeric feature, or undefined to use + * the source value. This is the integration point for rewritten index, + * episode_index, and task_index columns. + */ + resolveNumericRow?: ( + featureKey: string, + context: NumericStatsRowContext, + ) => NumericFeatureRow | undefined; + /** Supply unflattened per-episode stats if EpisodeMetadata does not contain stats/* fields. */ + getEpisodeStats?: ( + episode: EpisodeMetadata, + ) => + | Record> + | undefined + | Promise> | undefined>; +} + +const STAT_KEYS = ['min', 'max', 'mean', 'std', 'q01', 'q10', 'q50', 'q90', 'q99'] as const; +const QUANTILES = [ + ['q01', 0.01], + ['q10', 0.1], + ['q50', 0.5], + ['q90', 0.9], + ['q99', 0.99], +] as const; +const NUM_BINS = 5000; + +function isNumeric(feature: LeRobotFeature): boolean { + const dtype = feature.dtype.toLowerCase(); + return ( + dtype.includes('float') || dtype.includes('int') || dtype.includes('uint') || dtype === 'bool' + ); +} + +function isVisual(feature: LeRobotFeature): boolean { + const dtype = feature.dtype.toLowerCase(); + return ( + dtype === 'image' || + dtype === 'video' || + dtype === 'depth' || + feature.info?.is_depth_map === true + ); +} + +function featureDim(feature: LeRobotFeature): number { + return feature.shape.length > 0 ? feature.shape[0] : 1; } -/** Get dimension for a feature (number of scalar values per row). */ -function getFeatureDim(feature: LeRobotFeature): number { - const shape = feature.shape; - if (shape && shape.length > 0 && typeof shape[0] === 'number') return shape[0]; +function visualChannels(feature: LeRobotFeature): number { + const names = feature.names?.map((name) => name.toLowerCase()) ?? []; + const namedChannel = names.findIndex((name) => name === 'channel' || name === 'channels'); + if (namedChannel >= 0 && feature.shape[namedChannel] != null) return feature.shape[namedChannel]; + if (feature.shape.length >= 3) { + if (feature.shape[0] === 1 || feature.shape[0] === 3) return feature.shape[0]; + if (feature.shape.at(-1) === 1 || feature.shape.at(-1) === 3) return feature.shape.at(-1)!; + } return 1; } -/** Read a numeric value from Arrow (scalar or list) as number[]. */ -function getNumericRow( +function numericRow( vector: ReturnType, row: number, dim: number, ): number[] | null { - const val = vector?.get(row); - if (val === undefined || val === null) return null; - if (typeof val === 'number' && !Number.isNaN(val)) return [val]; - if (typeof val === 'bigint') return [Number(val)]; - if (Array.isArray(val)) { - const out = val.slice(0, dim).map((v) => (typeof v === 'bigint' ? Number(v) : Number(v))); - if (out.some((v) => Number.isNaN(v))) return null; - return out; - } - const obj = val as { toArray?: () => number[]; length?: number }; - if (typeof obj.toArray === 'function') { - const arr = obj.toArray().slice(0, dim); - if (arr.some((v) => Number.isNaN(v))) return null; - return arr; - } - return null; -} - -/** Running stats accumulator per dimension (Welford-friendly: n, mean, m2, min, max). */ -function createAccum(dim: number): { - n: number; - mean: number[]; - m2: number[]; - min: number[]; - max: number[]; -} { - return { - n: 0, - mean: new Array(dim).fill(0), - m2: new Array(dim).fill(0), - min: new Array(dim).fill(Number.POSITIVE_INFINITY), - max: new Array(dim).fill(Number.NEGATIVE_INFINITY), + const value = vector?.get(row); + if (typeof value === 'number') return Number.isFinite(value) && dim === 1 ? [value] : null; + if (typeof value === 'bigint') { + const converted = Number(value); + if (!Number.isSafeInteger(converted)) { + throw new RangeError(`Numeric feature contains unsafe int64 value: ${value}`); + } + return dim === 1 ? [converted] : null; + } + let values: unknown[] | undefined; + if (Array.isArray(value)) values = value; + else if (value && typeof (value as { toArray?: () => unknown[] }).toArray === 'function') { + values = Array.from((value as { toArray: () => unknown[] }).toArray()); + } + if (!values || values.length !== dim) return null; + const converted = values.map((item) => Number(item)); + if ( + converted.some( + (item, index) => + !Number.isFinite(item) || + (typeof values?.[index] === 'bigint' && !Number.isSafeInteger(item)), + ) + ) { + return null; + } + return converted; +} + +function plainArray(value: unknown): StatsArray | null { + if (typeof value === 'number') return Number.isFinite(value) ? [value] : null; + if (ArrayBuffer.isView(value) && !(value instanceof DataView)) { + return plainArray(Array.from(value as unknown as Iterable)); + } + if (!Array.isArray(value)) { + const arrayLike = value as { toArray?: () => unknown }; + return typeof arrayLike?.toArray === 'function' ? plainArray(arrayLike.toArray()) : null; + } + const output: StatsArray = []; + for (const item of value) { + if (typeof item === 'number') { + if (!Number.isFinite(item)) return null; + output.push(item); + } else { + const nested = plainArray(item); + if (!nested) return null; + output.push(nested); + } + } + return output; +} + +function shapeOf(value: StatsArray): number[] { + const shape: number[] = []; + let current: number | StatsArray = value; + while (Array.isArray(current)) { + shape.push(current.length); + if (current.length === 0) break; + const childShapes = current + .filter(Array.isArray) + .map((child) => JSON.stringify(shapeOf(child as StatsArray))); + if ( + current.some((child) => Array.isArray(child)) !== + current.every((child) => Array.isArray(child)) || + new Set(childShapes).size > 1 + ) { + throw new Error('Episode stats contain a ragged array'); + } + current = current[0]; + } + return shape; +} + +function flatten(value: StatsArray): number[] { + const result: number[] = []; + const visit = (item: number | StatsArray): void => { + if (typeof item === 'number') result.push(item); + else item.forEach(visit); }; + visit(value); + return result; } -function updateAccum(acc: ReturnType, values: number[]): void { - const dim = values.length; - acc.n += 1; - const n = acc.n; - for (let d = 0; d < dim; d++) { - const x = values[d]; - if (Number.isNaN(x) || !Number.isFinite(x)) continue; - const delta = x - acc.mean[d]; - acc.mean[d] += delta / n; - acc.m2[d] += delta * (x - acc.mean[d]); - if (x < acc.min[d]) acc.min[d] = x; - if (x > acc.max[d]) acc.max[d] = x; - } -} - -function toFeatureStats(acc: ReturnType, dim: number): FeatureStats { - const mean = acc.mean.slice(0, dim); - const std = new Array(dim).fill(0); - for (let d = 0; d < dim; d++) { - if (acc.n > 0) { - const variance = acc.m2[d] / acc.n; - std[d] = Math.sqrt(variance); +function reshape(values: readonly number[], shape: readonly number[]): StatsArray { + let offset = 0; + const build = (depth: number): StatsArray => + Array.from({ length: shape[depth] }, () => + depth === shape.length - 1 ? values[offset++] : build(depth + 1), + ); + return build(0); +} + +function validateStats( + raw: Record, + featureKey: string, + expectedShape?: readonly number[], +): FeatureStats { + const count = plainArray(raw.count); + if (!count || count.length !== 1 || typeof count[0] !== 'number' || count[0] <= 0) { + throw new Error( + `Cannot compute training-ready stats: invalid count for feature "${featureKey}"`, + ); + } + const result = { count: [count[0]] } as FeatureStats; + let shape: number[] | undefined; + for (const key of STAT_KEYS) { + const value = plainArray(raw[key]); + if (!value) { + throw new Error( + `Cannot compute training-ready stats: missing or invalid "${key}" for feature "${featureKey}"`, + ); } + const valueShape = shapeOf(value); + shape ??= valueShape; + if (JSON.stringify(shape) !== JSON.stringify(valueShape)) { + throw new Error( + `Cannot compute training-ready stats: inconsistent shape for "${featureKey}"`, + ); + } + result[key] = value; + } + if (expectedShape && JSON.stringify(shape) !== JSON.stringify(expectedShape)) { + throw new Error( + `Cannot compute training-ready stats: feature "${featureKey}" has stats shape ` + + `[${shape?.join(',')}] but official shape is [${expectedShape.join(',')}]`, + ); + } + return result; +} + +function flattenedEpisodeStats( + episode: EpisodeMetadata, + featureKey: string, +): Record | undefined { + const nested = (episode as Record).stats as + Record> | undefined; + if (nested?.[featureKey]) return nested[featureKey]; + const prefix = `stats/${featureKey}/`; + const result: Record = {}; + for (const [key, value] of Object.entries(episode)) { + if (key.startsWith(prefix)) result[key.slice(prefix.length)] = value; } - const min = acc.min.slice(0, dim).map((v) => (v === Number.POSITIVE_INFINITY ? 0 : v)); - const max = acc.max.slice(0, dim).map((v) => (v === Number.NEGATIVE_INFINITY ? 0 : v)); - return { min, max, mean, std, count: acc.n }; + return Object.keys(result).length > 0 ? result : undefined; +} + +function aggregateFeature(items: readonly FeatureStats[], featureKey: string): FeatureStats { + if (items.length === 0) throw new Error(`No episode stats for feature "${featureKey}"`); + const shape = shapeOf(items[0].mean); + const width = flatten(items[0].mean).length; + const counts = items.map((item) => item.count[0]); + const total = counts.reduce((sum, value) => sum + value, 0); + const values = items.map((item) => { + const output = {} as Record<(typeof STAT_KEYS)[number], number[]>; + for (const key of STAT_KEYS) { + if (JSON.stringify(shapeOf(item[key])) !== JSON.stringify(shape)) { + throw new Error(`Incompatible episode stats shape for feature "${featureKey}"`); + } + output[key] = flatten(item[key]); + } + return output; + }); + const mean = Array.from( + { length: width }, + (_, dim) => + values.reduce((sum, value, index) => sum + value.mean[dim] * counts[index], 0) / total, + ); + const variance = Array.from( + { length: width }, + (_, dim) => + values.reduce((sum, value, index) => { + const delta = value.mean[dim] - mean[dim]; + return sum + (value.std[dim] ** 2 + delta ** 2) * counts[index]; + }, 0) / total, + ); + const result = { + min: reshape( + Array.from({ length: width }, (_, dim) => Math.min(...values.map((value) => value.min[dim]))), + shape, + ), + max: reshape( + Array.from({ length: width }, (_, dim) => Math.max(...values.map((value) => value.max[dim]))), + shape, + ), + mean: reshape(mean, shape), + std: reshape( + variance.map((value) => Math.sqrt(Math.max(0, value))), + shape, + ), + count: [total], + } as FeatureStats; + for (const [key] of QUANTILES) { + result[key] = reshape( + Array.from( + { length: width }, + (_, dim) => + values.reduce((sum, value, index) => sum + value[key][dim] * counts[index], 0) / total, + ), + shape, + ); + } + return result; +} + +function histogramQuantile( + histogram: Uint32Array, + low: number, + high: number, + target: number, +): number { + let cumulative = 0; + let before = 0; + let index = 0; + for (; index < histogram.length; index++) { + before = cumulative; + cumulative += histogram[index]; + if (cumulative >= target) break; + } + if (index === 0) return low; + if (index >= histogram.length) return high; + const countInBin = cumulative - before; + const fraction = countInBin === 0 ? 0 : (target - before) / countInBin; + return low + (index + fraction) * ((high - low) / histogram.length); +} + +function computeNumericEpisode( + count: number, + dim: number, + readRow: (row: number) => number[], +): FeatureStats { + const min = new Array(dim).fill(Infinity); + const max = new Array(dim).fill(-Infinity); + const sum = new Array(dim).fill(0); + const squares = new Array(dim).fill(0); + for (let row = 0; row < count; row++) { + const values = readRow(row); + for (let d = 0; d < dim; d++) { + const value = values[d]; + min[d] = Math.min(min[d], value); + max[d] = Math.max(max[d], value); + sum[d] += value; + squares[d] += value * value; + } + } + const mean = sum.map((value) => value / count); + const std = squares.map((value, d) => Math.sqrt(Math.max(0, value / count - mean[d] * mean[d]))); + const result = { min, max, mean, std, count: [count] } as FeatureStats; + if (count < 2) { + for (const [key] of QUANTILES) result[key] = [...mean]; + return result; + } + const low = min.map((value, d) => + value === max[d] ? value - 1e-10 : value - (max[d] - value) * 1e-10, + ); + const high = max.map((value, d) => + value === min[d] ? value + 1e-10 : value + (value - min[d]) * 1e-10, + ); + const histograms = Array.from({ length: dim }, () => new Uint32Array(NUM_BINS)); + for (let row = 0; row < count; row++) { + const values = readRow(row); + for (let d = 0; d < dim; d++) { + const scaled = ((values[d] - low[d]) / (high[d] - low[d])) * NUM_BINS; + const index = Math.max(0, Math.min(NUM_BINS - 1, Math.floor(scaled))); + histograms[d][index]++; + } + } + for (const [key, quantile] of QUANTILES) { + result[key] = histograms.map((histogram, d) => + histogramQuantile(histogram, low[d], high[d], quantile * count), + ); + } + return result; +} + +/** Aggregate persisted episode stats with official count-weighted semantics. */ +export function aggregateEpisodeStats( + statsByEpisode: readonly Record>[], +): DatasetStats { + const result: DatasetStats = {}; + const featureKeys = new Set(statsByEpisode.flatMap((stats) => Object.keys(stats))); + for (const featureKey of featureKeys) { + result[featureKey] = aggregateFeature( + statsByEpisode + .map((stats) => stats[featureKey]) + .filter((stats): stats is FeatureStats | Record => stats !== undefined) + .map((stats) => validateStats(stats as Record, featureKey)), + featureKey, + ); + } + return result; } /** - * Compute global numeric feature statistics (min, max, mean, std) over all episodes. - * Streams episode tables and aggregates; reports progress and respects AbortSignal. + * Compute v0.6.1 training stats. Numeric data uses one bounded 5000-bin + * histogram per feature/episode; visual data is aggregated from persisted + * episode stats so media sampling is not silently changed. */ export async function computeDatasetStats( dataLoader: StatsDataLoader, info: LeRobotInfo, episodes: EpisodeMetadata[], - options?: { onProgress?: (current: number, total: number) => void; signal?: AbortSignal }, + options?: ComputeDatasetStatsOptions, ): Promise { - const result: DatasetStats = {}; - const numericKeys = new Set(); - const dims: Record = {}; - const accums: Record> = {}; - const total = episodes.length; - const progressInterval = Math.max(1, Math.floor(total / 50)); + if (episodes.length === 0) { + throw new Error('Cannot compute training-ready stats for a dataset with no episodes'); + } + const numericKeys = Object.entries(info.features) + .filter(([, feature]) => isNumeric(feature) && !isVisual(feature)) + .map(([key]) => key); + const visualKeys = Object.entries(info.features) + .filter(([, feature]) => isVisual(feature)) + .map(([key]) => key); + if (numericKeys.length === 0 && visualKeys.length === 0) { + throw new Error('Cannot compute training-ready stats: no trainable features are available'); + } - for (let i = 0; i < episodes.length; i++) { + const perFeature = new Map(); + let globalRow = 0; + for (let episodePosition = 0; episodePosition < episodes.length; episodePosition++) { if (options?.signal?.aborted) { - const err = new Error('Export cancelled'); - err.name = 'AbortError'; - throw err; + const error = new Error('Export cancelled'); + error.name = 'AbortError'; + throw error; } - if (i > 0 && i % progressInterval === 0) { - options?.onProgress?.(i, total); - } - - const ep = episodes[i]; - const { table } = await dataLoader.getEpisodeTableForExport(ep.episode_index); - - if (i === 0) { - for (const key of getNumericFeatureKeys(info, table)) { - numericKeys.add(key); - const feat = info.features[key]; - const dim = feat ? getFeatureDim(feat) : 1; - dims[key] = dim; - accums[key] = createAccum(dim); + const episode = episodes[episodePosition]; + const supplied = await options?.getEpisodeStats?.(episode); + for (const featureKey of visualKeys) { + const feature = info.features[featureKey]; + const raw = supplied?.[featureKey] ?? flattenedEpisodeStats(episode, featureKey); + if (!raw) { + throw new Error( + `Cannot compute training-ready stats: selected episode ${episode.episode_index} ` + + `is missing required visual stats for feature "${featureKey}"`, + ); } + const channels = visualChannels(feature); + const stats = validateStats(raw as Record, featureKey, [channels, 1, 1]); + const list = perFeature.get(featureKey) ?? []; + list.push(stats); + perFeature.set(featureKey, list); } - for (const key of numericKeys) { - const vector = table.getChild(key); - if (!vector) continue; - const dim = dims[key] ?? 1; - const accum = accums[key]; - for (let row = 0; row < table.numRows; row++) { - const values = getNumericRow(vector, row, dim); - if (values) updateAccum(accum, values); + if (numericKeys.length > 0) { + const { table } = await dataLoader.getEpisodeTableForExport(episode.episode_index); + if (table.numRows === 0) { + throw new Error( + `Cannot compute training-ready stats: episode ${episode.episode_index} is empty`, + ); + } + if (table.numRows !== episode.length) { + throw new Error( + `Cannot compute training-ready stats: episode ${episode.episode_index} row count ` + + `${table.numRows} does not match metadata length ${episode.length}`, + ); } + for (const featureKey of numericKeys) { + const vector = table.getChild(featureKey); + if (!vector) { + throw new Error( + `Cannot compute training-ready stats: episode ${episode.episode_index} ` + + `is missing feature "${featureKey}"`, + ); + } + const dim = featureDim(info.features[featureKey]); + const readRow = (row: number): number[] => { + const sourceValues = numericRow(vector, row, dim); + if (!sourceValues) { + throw new Error( + `Cannot compute training-ready stats: feature "${featureKey}" has an invalid row`, + ); + } + const resolved = options?.resolveNumericRow?.(featureKey, { + episode, + outputEpisodeIndex: episodePosition, + rowIndex: row, + outputGlobalIndex: globalRow + row, + sourceValues, + }); + const values = + resolved === undefined + ? sourceValues + : typeof resolved === 'number' + ? [resolved] + : Array.from(resolved); + if (values.length !== dim || values.some((value) => !Number.isFinite(value))) { + throw new Error( + `Cannot compute training-ready stats: final value for "${featureKey}" is invalid`, + ); + } + return values; + }; + const list = perFeature.get(featureKey) ?? []; + list.push(computeNumericEpisode(table.numRows, dim, readRow)); + perFeature.set(featureKey, list); + } + globalRow += table.numRows; + } else { + globalRow += episode.length; } + options?.onProgress?.(episodePosition + 1, episodes.length); } - options?.onProgress?.(total, total); - - for (const key of numericKeys) { - const dim = dims[key] ?? 1; - result[key] = toFeatureStats(accums[key], dim); + const result: DatasetStats = {}; + for (const [featureKey, values] of perFeature) { + result[featureKey] = aggregateFeature(values, featureKey); } - return result; } diff --git a/src/core/datasource/types.ts b/src/core/datasource/types.ts index 1c1a527..ff5d823 100644 --- a/src/core/datasource/types.ts +++ b/src/core/datasource/types.ts @@ -1,19 +1,32 @@ +/** A phase reported while a data source reads or prepares content. */ export type LoadingPhase = 'download' | 'index' | 'gunzip' | 'read'; +/** Progress reported by a data-source operation. */ export interface ProgressInfo { + /** Current operation phase. */ phase: LoadingPhase; + /** Bytes or logical units processed so far. */ loaded?: number; + /** Total bytes or logical units when known. */ total?: number; + /** Optional human-readable detail. */ message?: string; } +/** Receives data-source progress updates. */ export type ProgressHandler = (info: ProgressInfo) => void; +/** Browser-readable storage abstraction consumed by the viewer. */ export interface DataSource { + /** Return whether a logical dataset path exists. */ exists(path: string): Promise; + /** Read a UTF-8 text file. */ readText(path: string, onProgress?: ProgressHandler): Promise; + /** Read a file as bytes. */ readBytes(path: string, onProgress?: ProgressHandler): Promise; + /** Return an object URL or remote URL suitable for browser media APIs. */ getObjectUrl(path: string, mimeType?: string, onProgress?: ProgressHandler): Promise; + /** Release object URLs, handles, caches, and other source-owned resources. */ clear(): void | Promise; /** * Lists logical dataset paths when the backing source has an index. diff --git a/src/core/export/types.ts b/src/core/export/types.ts index aeaa212..91a1498 100644 --- a/src/core/export/types.ts +++ b/src/core/export/types.ts @@ -35,7 +35,21 @@ export interface ExportOptions { splitsConfig?: SplitConfig; } -/** Per-episode video offsets for v3 (one file per chunk/key, segment by from_timestamp/to_timestamp). */ +export interface V3DataEpisodeLocation { + chunk_index: number; + file_index: number; + dataset_from_index: number; + dataset_to_index: number; +} + +/** Actual v3 data shards produced by the exporter, keyed by source episode index. */ +export interface V3DataLayout { + episodes: Map; + total_chunks: number; + total_files: number; +} + +/** Per-episode video offsets for v3, keyed by source episode index and video feature. */ export type EpisodeVideoOffsets = Map< number, Record< diff --git a/src/core/index.ts b/src/core/index.ts index 239ccca..f14a519 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -21,12 +21,22 @@ export type { ExportOptions, TargetVersion, EpisodeVideoOffsets, + V3DataEpisodeLocation, + V3DataLayout, } from './export/types'; export { computeSplits, splitsIndicesToInfoSplits } from './analysis/SplitService'; export type { SplitStrategy, SplitConfig, SplitsIndices } from './analysis/SplitService'; -export type { FeatureStats, DatasetStats, StatsDataLoader } from './analysis/StatsService'; -export { computeDatasetStats } from './analysis/StatsService'; +export type { + FeatureStats, + DatasetStats, + StatsArray, + StatsDataLoader, + NumericFeatureRow, + NumericStatsRowContext, + ComputeDatasetStatsOptions, +} from './analysis/StatsService'; +export { aggregateEpisodeStats, computeDatasetStats } from './analysis/StatsService'; export { getEagerEpisodeColumns, diff --git a/src/core/types/lerobot.ts b/src/core/types/lerobot.ts index 0fdd01b..7a13b14 100644 --- a/src/core/types/lerobot.ts +++ b/src/core/types/lerobot.ts @@ -9,6 +9,7 @@ export interface LeRobotFeature { info?: { [key: string]: string | number | boolean | null; }; + [key: string]: unknown; } /** @@ -16,6 +17,7 @@ export interface LeRobotFeature { */ export interface BaseLeRobotInfo { codebase_version: string; + /** Some official metadata writes null; validators accept it at runtime. */ robot_type: string; total_episodes: number; total_frames: number; @@ -24,15 +26,17 @@ export interface BaseLeRobotInfo { total_videos?: number; fps: number; data_path: string; - video_path: string; + video_path: string | null; + tools?: unknown[] | null; features: Record; + [key: string]: unknown; } /** * Specialized Info for v2.x */ export interface LeRobotInfoV2 extends BaseLeRobotInfo { - codebase_version: 'v2.0' | 'v2.1'; + codebase_version: 'v2.1'; chunks_size: number; } @@ -55,6 +59,7 @@ export type LeRobotInfo = LeRobotInfoV2 | LeRobotInfoV3; export interface BaseEpisodeMetadata { episode_index: number; length: number; + [key: string]: unknown; } /** @@ -76,7 +81,7 @@ export interface EpisodeMetadataV3 extends BaseEpisodeMetadata { chunk_index?: number; file_index?: number; // Specific video keys like observation.images.up - [key: string]: string | number | string[] | undefined; + [key: string]: unknown; } export type EpisodeMetadata = EpisodeMetadataV2 | EpisodeMetadataV3; @@ -102,11 +107,11 @@ export type PlaybackMode = 'loop' | 'sequential' | 'shuffle'; * Type Guards */ export function isV3Info(info: LeRobotInfo): info is LeRobotInfoV3 { - return info.codebase_version.startsWith('v3'); + return info.codebase_version.trim().toLowerCase() === 'v3.0'; } export function isV2Info(info: LeRobotInfo): info is LeRobotInfoV2 { - return info.codebase_version.startsWith('v2'); + return info.codebase_version.trim().toLowerCase() === 'v2.1'; } export function isV3Metadata(meta: EpisodeMetadata): meta is EpisodeMetadataV3 { diff --git a/src/core/versioning/LeRobotVersionAdapter.ts b/src/core/versioning/LeRobotVersionAdapter.ts index 33b5bdf..da464cd 100644 --- a/src/core/versioning/LeRobotVersionAdapter.ts +++ b/src/core/versioning/LeRobotVersionAdapter.ts @@ -8,7 +8,7 @@ import type { import { normalizeTaskDisplay } from './arrowUtils'; /** - * Abstract base for LeRobot codebase version adapters (v2.0, v2.1, v3.0, etc.). + * Abstract base for LeRobot codebase version adapters. * Subclasses must implement version and the four methods. Path resolution and * metadata loading are delegated to the adapter so DataLoader, panels, and * export do not branch on version. diff --git a/src/core/versioning/arrowUtils.ts b/src/core/versioning/arrowUtils.ts index 38e0e85..af9f89a 100644 --- a/src/core/versioning/arrowUtils.ts +++ b/src/core/versioning/arrowUtils.ts @@ -19,7 +19,15 @@ function tryParseJsonString(str: string): unknown { export function convertArrowValue(val: unknown): unknown { if (val === null || val === undefined) return val; - if (typeof val === 'bigint') return Number(val); + if (typeof val === 'bigint') { + const converted = Number(val); + if (!Number.isSafeInteger(converted)) { + throw new RangeError( + `Parquet int64 value is outside JavaScript's safe integer range: ${val}`, + ); + } + return converted; + } if (typeof val === 'string') return tryParseJsonString(val); if (typeof val !== 'object') return val; diff --git a/src/core/versioning/index.ts b/src/core/versioning/index.ts index 8a05df9..f4668ea 100644 --- a/src/core/versioning/index.ts +++ b/src/core/versioning/index.ts @@ -14,9 +14,17 @@ export { createReport, BaseLeRobotValidator } from './validation'; export { getAdapterForVersion, getValidatorForVersion, + isSupportedLeRobotVersion, + normalizeLeRobotVersion, v2Adapter, v3Adapter, } from './versionRegistry'; +export { classifyLeRobotVersion } from './versionCapability'; +export type { + LeRobotVersionCapability, + LeRobotVersionCapabilityStatus, + SupportedLeRobotVersion, +} from './versionCapability'; export { V2Adapter } from './v2Adapter'; export { V3Adapter } from './v3Adapter'; export { V2FormatValidator } from './v2FormatValidator'; diff --git a/src/core/versioning/v2Adapter.ts b/src/core/versioning/v2Adapter.ts index 62aadfa..93854ab 100644 --- a/src/core/versioning/v2Adapter.ts +++ b/src/core/versioning/v2Adapter.ts @@ -40,7 +40,7 @@ function normalizeEpisodeTasks(tasks: unknown): string[] { } /** - * Adapter for LeRobot codebase v2.0 / v2.1. + * Adapter for LeRobot codebase v2.1 and newer read-only v2 compatibility. * - Episodes: meta/episodes.jsonl * - Tasks: meta/tasks.jsonl * - Data: one Parquet file per episode, data/chunk-XXX/episode_YYYYYY.parquet, full file = one episode @@ -109,6 +109,29 @@ export class V2Adapter extends LeRobotVersionAdapter { episodes.forEach((ep) => { ep.tasks = normalizeEpisodeTasks(ep.tasks); }); + try { + const statsText = await dataSource.readText('meta/episodes_stats.jsonl'); + const statsByEpisode = new Map>(); + for (const line of statsText.split('\n').filter((item) => item.trim())) { + const row = JSON.parse(line) as { + episode_index: number; + stats?: Record>; + }; + const flattened: Record = {}; + for (const [featureKey, featureStats] of Object.entries(row.stats ?? {})) { + for (const [statKey, value] of Object.entries(featureStats)) { + flattened[`stats/${featureKey}/${statKey}`] = value; + } + } + statsByEpisode.set(row.episode_index, flattened); + } + episodes.forEach((episode) => { + Object.assign(episode, statsByEpisode.get(episode.episode_index)); + }); + } catch { + // v2.1 permits datasets without legacy per-episode stats. Training + // export will reject missing visual stats explicitly when required. + } return episodes; } catch (e) { console.warn('Failed to load episodes.jsonl', e); diff --git a/src/core/versioning/v2FormatValidator.ts b/src/core/versioning/v2FormatValidator.ts index 011c3e5..189a256 100644 --- a/src/core/versioning/v2FormatValidator.ts +++ b/src/core/versioning/v2FormatValidator.ts @@ -2,13 +2,14 @@ import type { LeRobotInfo } from '../types/lerobot'; import type { DataSource } from '../datasource/types'; import type { MetadataLoadingHelpers } from './types'; import { BaseLeRobotValidator, createReport } from './validation'; +import { classifyLeRobotVersion } from './versionCapability'; const INFO_JSON = 'meta/info.json'; const EPISODES_JSONL = 'meta/episodes.jsonl'; const TASKS_JSONL = 'meta/tasks.jsonl'; /** - * Comprehensive validator for LeRobot v2.0 / v2.1 datasets. + * Comprehensive validator for LeRobot v2.1 and same-major read-only datasets. * Checks: file structure, meta/info.json fields, features, episodes consistency. */ export class V2FormatValidator extends BaseLeRobotValidator { @@ -57,32 +58,43 @@ export class V2FormatValidator extends BaseLeRobotValidator { } // ── 2. codebase_version ───────────────────────────────────────────────── - const ver = (resolvedInfo.codebase_version || '').trim().toLowerCase(); - if (!ver.startsWith('v2')) { + const versionCapability = classifyLeRobotVersion(resolvedInfo.codebase_version); + if (versionCapability.adapterVersion !== 'v2.1') { this.fail( 'meta_info', 'codebase_version', 'VERSION_MISMATCH', - 'codebase_version is not v2.x', + 'codebase_version is not supported by the v2.1 reader', resolvedInfo.codebase_version ?? 'Missing', - '"v2.0" or "v2.1"', - 'If this is v3.0, use the correct loader; otherwise set codebase_version to v2.0 or v2.1', + '"v2.1"', + 'Use a v2.1 dataset, or the matching reader for another major version', ); return createReport(this.items); } + if (versionCapability.status === 'read-only') { + this.warn( + 'meta_info', + 'codebase_version', + 'VERSION_READ_ONLY', + 'This newer v2 version is opened in read-only compatibility mode', + `"${resolvedInfo.codebase_version}"`, + '"v2.1" for export', + 'Convert with an official compatible tool before exporting', + ); + } this.pass( 'meta_info', 'codebase_version', `"${resolvedInfo.codebase_version}"`, - '"v2.0" or "v2.1"', + '"v2.1" or a newer read-only v2 minor', ); // ── 3. Meta info fields ────────────────────────────────────────────────── this.checkScalar( resolvedInfo.robot_type, 'robot_type', - 'Non-empty string', - (v) => typeof v === 'string' && (v as string).trim().length > 0, + 'Non-empty string or null', + (v) => v === null || (typeof v === 'string' && (v as string).trim().length > 0), 'ROBOT_TYPE_MISSING', 'robot_type is missing or empty', 'Set robot type, e.g. "so100" or "lekiwi"', diff --git a/src/core/versioning/v3Adapter.ts b/src/core/versioning/v3Adapter.ts index 549a14f..5f2a049 100644 --- a/src/core/versioning/v3Adapter.ts +++ b/src/core/versioning/v3Adapter.ts @@ -19,17 +19,18 @@ const TASKS_PARQUET_PATH = 'meta/tasks.parquet'; const DEFAULT_CHUNKS_SIZE = 1000; const DEFAULT_DATA_PATH = 'data/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet'; const DEFAULT_VIDEO_PATH = 'videos/{video_key}/chunk-{chunk_index:03d}/file-{file_index:03d}.mp4'; -const BASE_EPISODE_COLUMNS = [ - 'episode_index', - 'length', - 'task_index', - 'dataset_from_index', - 'dataset_to_index', - 'chunk_index', - 'file_index', - 'data/chunk_index', - 'data/file_index', -] as const; + +function normalizeV3EpisodeTasks(rawTasks: unknown, taskMap: Record): string[] { + const values = Array.isArray(rawTasks) ? rawTasks : rawTasks == null ? [] : [rawTasks]; + return values + .map((value) => { + if (typeof value === 'number' && Number.isSafeInteger(value)) { + return taskMap[value] ?? ''; + } + return normalizeTaskDisplay(value).trim(); + }) + .filter((value) => value.length > 0); +} function pad(n: number): string { return String(n).padStart(3, '0'); @@ -69,20 +70,6 @@ function getDataFileValue(episode: EpisodeMetadata): unknown { return record['data/file_index'] ?? record.file_index; } -function getEpisodeColumnsToLoad(info?: LeRobotInfo): string[] { - const columns = new Set(BASE_EPISODE_COLUMNS); - - Object.entries(info?.features ?? {}).forEach(([featureKey, feature]) => { - if (feature?.dtype !== 'video') return; - columns.add(`videos/${featureKey}/chunk_index`); - columns.add(`videos/${featureKey}/file_index`); - columns.add(`videos/${featureKey}/from_timestamp`); - columns.add(`videos/${featureKey}/to_timestamp`); - }); - - return Array.from(columns); -} - /** * Adapter for LeRobot codebase v3.0. * - Episodes: meta/episodes/ directory (one or more parquet files, all loaded) @@ -166,7 +153,6 @@ export class V3Adapter extends LeRobotVersionAdapter { const chunksSize = ((info as Record | undefined)?.chunks_size as number | undefined) ?? DEFAULT_CHUNKS_SIZE; - const requestedColumns = getEpisodeColumnsToLoad(info); const allEpisodes: EpisodeMetadataV3[] = []; let chunkIdx = 0; let fileIdx = 0; @@ -188,7 +174,10 @@ export class V3Adapter extends LeRobotVersionAdapter { const path = paths?.[pathIndex] ?? episodeFilePath(chunkIdx, fileIdx); let ipcBytes: Uint8Array; try { - ipcBytes = await helpers.readParquetToIPC(path, requestedColumns); + // Load the complete schema. Episode parquet also carries per-episode + // stats and forward-compatible extension columns that must survive an + // edit/export roundtrip. + ipcBytes = await helpers.readParquetToIPC(path); } catch { if (paths) { throw new Error(`Failed to read v3 episode metadata shard: ${path}`); @@ -237,6 +226,11 @@ export class V3Adapter extends LeRobotVersionAdapter { ); }); + const taskMap = await this.loadTasks(dataSource, helpers); + allEpisodes.forEach((episode) => { + episode.tasks = normalizeV3EpisodeTasks((episode as Record).tasks, taskMap); + }); + return allEpisodes; } @@ -266,7 +260,8 @@ export class V3Adapter extends LeRobotVersionAdapter { tasks[index] = display !== '' ? display : 'Unknown Task'; } return tasks; - } catch { + } catch (error) { + if (error instanceof RangeError) throw error; try { const text = await dataSource.readText('meta/tasks.jsonl'); return LeRobotVersionAdapter.parseTasksFromJsonl(text); diff --git a/src/core/versioning/v3FormatValidator.ts b/src/core/versioning/v3FormatValidator.ts index dac745a..a30df23 100644 --- a/src/core/versioning/v3FormatValidator.ts +++ b/src/core/versioning/v3FormatValidator.ts @@ -4,6 +4,7 @@ import type { DataSource } from '../datasource/types'; import type { MetadataLoadingHelpers } from './types'; import { BaseLeRobotValidator, createReport } from './validation'; import { convertArrowValue } from './arrowUtils'; +import { classifyLeRobotVersion } from './versionCapability'; const INFO_JSON = 'meta/info.json'; const EPISODES_PARQUET_PATH = 'meta/episodes/chunk-000/file-000.parquet'; @@ -62,20 +63,36 @@ export class V3FormatValidator extends BaseLeRobotValidator { } // ── 2. codebase_version ───────────────────────────────────────────────── - const ver = (resolvedInfo.codebase_version || '').trim().toLowerCase(); - if (!ver.startsWith('v3')) { + const versionCapability = classifyLeRobotVersion(resolvedInfo.codebase_version); + if (versionCapability.adapterVersion !== 'v3.0') { this.fail( 'meta_info', 'codebase_version', 'VERSION_MISMATCH_V3', - 'codebase_version is not v3.x', + 'codebase_version is not supported by the v3.0 reader', resolvedInfo.codebase_version ?? 'Missing', '"v3.0"', 'Set codebase_version to v3.0', ); return createReport(this.items); } - this.pass('meta_info', 'codebase_version', `"${resolvedInfo.codebase_version}"`, '"v3.0"'); + if (versionCapability.status === 'read-only') { + this.warn( + 'meta_info', + 'codebase_version', + 'VERSION_READ_ONLY', + 'This newer v3 version is opened in read-only compatibility mode', + `"${resolvedInfo.codebase_version}"`, + '"v3.0" for export', + 'Convert with an official compatible tool before exporting', + ); + } + this.pass( + 'meta_info', + 'codebase_version', + `"${resolvedInfo.codebase_version}"`, + '"v3.0" or a newer read-only v3 minor', + ); const infoV3 = resolvedInfo as LeRobotInfo & { data_path?: string; @@ -133,8 +150,8 @@ export class V3FormatValidator extends BaseLeRobotValidator { this.checkScalar( resolvedInfo.robot_type, 'robot_type', - 'Non-empty string', - (v) => typeof v === 'string' && (v as string).trim().length > 0, + 'Non-empty string or null', + (v) => v === null || (typeof v === 'string' && (v as string).trim().length > 0), 'ROBOT_TYPE_MISSING', 'robot_type is missing or empty', 'Set robot type, e.g. "so100" or "lekiwi"', diff --git a/src/core/versioning/validation.ts b/src/core/versioning/validation.ts index 7a00282..f9bf7bc 100644 --- a/src/core/versioning/validation.ts +++ b/src/core/versioning/validation.ts @@ -196,6 +196,8 @@ export abstract class BaseLeRobotValidator { 'image', 'video', 'string', + 'language', + 'depth', ]); const DEFAULT_FEATURE_KEYS = new Set([ diff --git a/src/core/versioning/versionCapability.ts b/src/core/versioning/versionCapability.ts new file mode 100644 index 0000000..21cf3d4 --- /dev/null +++ b/src/core/versioning/versionCapability.ts @@ -0,0 +1,48 @@ +export type SupportedLeRobotVersion = 'v2.1' | 'v3.0'; +export type LeRobotVersionCapabilityStatus = 'supported' | 'read-only' | 'unsupported'; + +export interface LeRobotVersionCapability { + status: LeRobotVersionCapabilityStatus; + normalizedVersion: string | null; + adapterVersion: SupportedLeRobotVersion | null; +} + +export function normalizeLeRobotVersion(codebaseVersion: unknown): string | null { + if (typeof codebaseVersion !== 'string') return null; + const normalized = codebaseVersion.trim().toLowerCase(); + return normalized === '' ? null : normalized; +} + +/** + * Exact releases are writable. Strictly newer minor releases in a known major + * may be parsed by the latest same-major adapter, but remain read-only. + */ +export function classifyLeRobotVersion(codebaseVersion: unknown): LeRobotVersionCapability { + const normalizedVersion = normalizeLeRobotVersion(codebaseVersion); + if (normalizedVersion === 'v2.1' || normalizedVersion === 'v3.0') { + return { + status: 'supported', + normalizedVersion, + adapterVersion: normalizedVersion, + }; + } + + const match = normalizedVersion ? /^v(2|3)\.(0|[1-9]\d*)$/.exec(normalizedVersion) : null; + if (match) { + const major = Number(match[1]); + const minor = Number(match[2]); + if (Number.isSafeInteger(minor) && ((major === 2 && minor > 1) || (major === 3 && minor > 0))) { + return { + status: 'read-only', + normalizedVersion, + adapterVersion: major === 2 ? 'v2.1' : 'v3.0', + }; + } + } + + return { status: 'unsupported', normalizedVersion, adapterVersion: null }; +} + +export function isSupportedLeRobotVersion(codebaseVersion: unknown): boolean { + return classifyLeRobotVersion(codebaseVersion).status === 'supported'; +} diff --git a/src/core/versioning/versionRegistry.ts b/src/core/versioning/versionRegistry.ts index b24271d..b73145b 100644 --- a/src/core/versioning/versionRegistry.ts +++ b/src/core/versioning/versionRegistry.ts @@ -4,18 +4,22 @@ import { v2Adapter } from './v2Adapter'; import { v3Adapter } from './v3Adapter'; import { V2FormatValidator } from './v2FormatValidator'; import { V3FormatValidator } from './v3FormatValidator'; +import { + classifyLeRobotVersion, + isSupportedLeRobotVersion, + normalizeLeRobotVersion, +} from './versionCapability'; const v2FormatValidator = new V2FormatValidator(); const v3FormatValidator = new V3FormatValidator(); /** - * Returns the adapter for the given codebase_version (e.g. 'v2.0', 'v2.1', 'v3.0'). - * Uses prefix match: v2 -> v2Adapter, v3 -> v3Adapter. + * Returns an adapter for supported or same-major read-only versions. */ export function getAdapterForVersion(codebaseVersion: string): LeRobotVersionAdapter { - const normalized = codebaseVersion.trim().toLowerCase(); - if (normalized.startsWith('v3')) return v3Adapter; - if (normalized.startsWith('v2')) return v2Adapter; + const capability = classifyLeRobotVersion(codebaseVersion); + if (capability.adapterVersion === 'v3.0') return v3Adapter; + if (capability.adapterVersion === 'v2.1') return v2Adapter; throw new Error(`Unsupported LeRobot codebase_version: ${codebaseVersion}`); } @@ -24,10 +28,11 @@ export function getAdapterForVersion(codebaseVersion: string): LeRobotVersionAda * Run before load; use report.hasError to decide whether to proceed. */ export function getValidatorForVersion(codebaseVersion: string): BaseLeRobotValidator { - const normalized = codebaseVersion.trim().toLowerCase(); - if (normalized.startsWith('v3')) return v3FormatValidator; - if (normalized.startsWith('v2')) return v2FormatValidator; + const capability = classifyLeRobotVersion(codebaseVersion); + if (capability.adapterVersion === 'v3.0') return v3FormatValidator; + if (capability.adapterVersion === 'v2.1') return v2FormatValidator; throw new Error(`Unsupported LeRobot codebase_version: ${codebaseVersion}`); } +export { isSupportedLeRobotVersion, normalizeLeRobotVersion }; export { v2Adapter, v3Adapter }; diff --git a/src/platform/datasource/ArchiveDataSourceFactory.ts b/src/platform/datasource/ArchiveDataSourceFactory.ts index 125603b..913c71a 100644 --- a/src/platform/datasource/ArchiveDataSourceFactory.ts +++ b/src/platform/datasource/ArchiveDataSourceFactory.ts @@ -1,4 +1,4 @@ -import type { ProgressHandler } from './dataSources'; +import type { DataSource, ProgressHandler } from './dataSources'; import { TarDataSourceHttp, TarDataSourceLocal, @@ -47,11 +47,18 @@ export function getArchiveKindFromHeaders(headers: Headers): ArchiveKind | null if (cdLower.includes('.tar')) return 'tar'; if (cdLower.includes('.zip')) return 'zip'; - // content-type fallback (rough, but useful for same-origin/proxy downloads) - const ct = (headers.get('content-type') || '').toLowerCase(); - if (ct.includes('zip')) return 'zip'; - if (ct.includes('gzip')) return 'targz'; - if (ct.includes('x-tar') || ct.includes('tar')) return 'tar'; + // Content-Type fallback. Compare the media type rather than using substring + // matching: "application/gzip" itself contains "zip". + const contentType = (headers.get('content-type') || '').split(';', 1)[0].trim().toLowerCase(); + if (contentType === 'application/gzip' || contentType === 'application/x-gzip') return 'targz'; + if ( + contentType === 'application/zip' || + contentType === 'application/x-zip-compressed' || + contentType === 'multipart/x-zip' + ) { + return 'zip'; + } + if (contentType === 'application/x-tar' || contentType === 'application/tar') return 'tar'; return null; } @@ -92,7 +99,7 @@ export function createArchiveDataSourceFromUrl( onProgress?: ProgressHandler, knownKind?: ArchiveKind, options?: { accessMode?: RemoteArchiveAccessMode }, -) { +): DataSource { const kind = knownKind || getArchiveKindFromUrl(url); if (!kind) throw new Error('Unsupported archive type (need .zip/.tar/.tar.gz/.tgz)'); if (options?.accessMode === 'full') { @@ -103,7 +110,10 @@ export function createArchiveDataSourceFromUrl( return new ZipDataSourceHttp(url); } -export function createArchiveDataSourceFromFile(file: File, onProgress?: ProgressHandler) { +export function createArchiveDataSourceFromFile( + file: File, + onProgress?: ProgressHandler, +): DataSource { const kind = getArchiveKindFromFile(file); if (!kind) throw new Error('Unsupported archive type (need .zip/.tar/.tar.gz/.tgz)'); if (kind === 'targz') return new TarGzDataSourceLocal(file, onProgress); diff --git a/src/platform/datasource/RemoteManifestDataSource.ts b/src/platform/datasource/RemoteManifestDataSource.ts index f282a5c..8cb875c 100644 --- a/src/platform/datasource/RemoteManifestDataSource.ts +++ b/src/platform/datasource/RemoteManifestDataSource.ts @@ -1,4 +1,11 @@ import type { DataSource, ProgressHandler } from './dataSources'; +import { + assertMaterializable, + DataSourceSafetyError, + type DataSourceSafetyLimits, + resolveSafetyLimits, + validateUntrustedPath, +} from './inputSafety'; export interface RemoteFileEntry { logicalPath: string; @@ -24,51 +31,106 @@ function normalizePath(path: string): string { * remote datasets without loading all data upfront. */ export class RemoteManifestDataSource implements DataSource { - private readonly urlMap: Map; + private readonly entryMap = new Map(); + private readonly limits: Readonly; + + constructor(files: RemoteFileEntry[], limitOverrides?: Partial) { + this.limits = resolveSafetyLimits(limitOverrides); + if (files.length > this.limits.maxManifestEntries) { + throw new DataSourceSafetyError('ENTRY_COUNT_LIMIT', 'Manifest contains too many entries', { + count: files.length, + limit: this.limits.maxManifestEntries, + }); + } - constructor(files: RemoteFileEntry[]) { - this.urlMap = new Map(files.map((f) => [normalizePath(f.logicalPath), f.presignedUrl])); + let totalBytes = 0; + for (const file of files) { + const path = validateUntrustedPath(file.logicalPath, this.limits); + if (this.entryMap.has(path)) { + throw new DataSourceSafetyError('DUPLICATE_PATH', 'Manifest contains a duplicate path', { + path, + }); + } + validateManifestUrl(file.presignedUrl, this.limits, path); + if (file.sizeBytes != null && (!Number.isSafeInteger(file.sizeBytes) || file.sizeBytes < 0)) { + throw new DataSourceSafetyError('MANIFEST_INVALID', 'Manifest entry has an invalid size', { + path, + size: file.sizeBytes, + }); + } + if (file.sizeBytes != null) { + if (file.sizeBytes > this.limits.maxManifestEntryBytes) { + throw new DataSourceSafetyError( + 'ENTRY_SIZE_LIMIT', + 'Manifest entry exceeds the size limit', + { path, size: file.sizeBytes, limit: this.limits.maxManifestEntryBytes }, + ); + } + totalBytes += file.sizeBytes; + if (!Number.isSafeInteger(totalBytes) || totalBytes > this.limits.maxManifestTotalBytes) { + throw new DataSourceSafetyError( + 'TOTAL_SIZE_LIMIT', + 'Manifest exceeds the total size limit', + { total: totalBytes, limit: this.limits.maxManifestTotalBytes }, + ); + } + } + this.entryMap.set(path, file); + } } async exists(path: string): Promise { - return this.urlMap.has(normalizePath(path)); + return this.entryMap.has(normalizePath(path)); } async readText(path: string): Promise { - const url = this.getUrl(path); - const res = await fetch(url); - if (!res.ok) { - throw new Error(`HTTP ${res.status} fetching ${path}`); - } - return res.text(); + return new TextDecoder().decode(await this.readBytes(path)); } async readBytes(path: string, onProgress?: ProgressHandler): Promise { - const url = this.getUrl(path); + const entry = this.getEntry(path); + const url = entry.presignedUrl; + assertMaterializable(path, entry.sizeBytes ?? undefined, this.limits); const res = await fetch(url); if (!res.ok) { throw new Error(`HTTP ${res.status} fetching ${path}`); } const contentLength = res.headers.get('content-length'); - const total = contentLength ? parseInt(contentLength, 10) : undefined; - - if (!onProgress || !res.body) { - return new Uint8Array(await res.arrayBuffer()); + const total = contentLength ? Number(contentLength) : undefined; + if (total !== undefined && (!Number.isSafeInteger(total) || total < 0)) { + throw new DataSourceSafetyError( + 'MANIFEST_INVALID', + 'Response has an invalid content length', + { + path, + contentLength, + }, + ); } + assertMaterializable(path, total, this.limits); + if (!res.body) throw new Error(`HTTP response has no readable body fetching ${path}`); const reader = res.body.getReader(); const chunks: Uint8Array[] = []; let loaded = 0; - onProgress({ phase: 'download', loaded: 0, total, message: path }); + onProgress?.({ phase: 'download', loaded: 0, total, message: path }); while (true) { const { done, value } = await reader.read(); if (done) break; - chunks.push(value); loaded += value.byteLength; - onProgress({ phase: 'download', loaded, total, message: path }); + if (loaded > this.limits.maxMaterializedBytes) { + await reader.cancel(); + throw new DataSourceSafetyError( + 'MATERIALIZED_SIZE_LIMIT', + 'Remote file exceeded the in-memory size limit while downloading', + { path, loaded, limit: this.limits.maxMaterializedBytes }, + ); + } + chunks.push(value); + onProgress?.({ phase: 'download', loaded, total, message: path }); } const result = new Uint8Array(loaded); @@ -97,10 +159,36 @@ export class RemoteManifestDataSource implements DataSource { } private getUrl(path: string): string { - const url = this.urlMap.get(normalizePath(path)); - if (!url) { + return this.getEntry(path).presignedUrl; + } + + private getEntry(path: string): RemoteFileEntry { + const entry = this.entryMap.get(normalizePath(path)); + if (!entry) { throw new Error(`File not found in manifest: ${path}`); } - return url; + return entry; + } +} + +function validateManifestUrl( + rawUrl: string, + limits: Readonly, + path: string, +): void { + if (!rawUrl || rawUrl.length > limits.maxManifestUrlLength) { + throw new DataSourceSafetyError('URL_INVALID', 'Manifest URL is empty or too long', { + path, + urlLength: rawUrl?.length ?? 0, + limit: limits.maxManifestUrlLength, + }); + } + try { + const url = new URL(rawUrl); + if (url.protocol !== 'http:' && url.protocol !== 'https:') + throw new Error('unsupported protocol'); + if (!url.hostname || url.username || url.password) throw new Error('invalid authority'); + } catch { + throw new DataSourceSafetyError('URL_INVALID', 'Manifest URL must be an HTTP(S) URL', { path }); } } diff --git a/src/platform/datasource/dataSources.ts b/src/platform/datasource/dataSources.ts index a8fd46a..e9857b0 100644 --- a/src/platform/datasource/dataSources.ts +++ b/src/platform/datasource/dataSources.ts @@ -1,5 +1,13 @@ import { ZipReader, BlobReader, HttpReader, TextWriter, Uint8ArrayWriter } from '@zip.js/zip.js'; -import { gunzipSync } from 'fflate'; +import { Gunzip } from 'fflate'; +import { + assertMaterializable, + DataSourceSafetyError, + type DataSourceSafetyLimits, + resolveSafetyLimits, + validateEntrySizes, + validateUntrustedPath, +} from './inputSafety'; /** * 简易 LRU cache for blob URLs. @@ -118,9 +126,11 @@ abstract class BaseZipDataSource implements DataSource { protected objectUrlCache: BlobUrlLruCache = new BlobUrlLruCache(); protected objectUrlLoading: Map> = new Map(); protected initPromise: Promise | null = null; + protected readonly limits: Readonly; - constructor(zipReader: ZipReader) { + constructor(zipReader: ZipReader, limitOverrides?: Partial) { this.zipReader = zipReader; + this.limits = resolveSafetyLimits(limitOverrides); } protected resolvePath(path: string): string { @@ -136,13 +146,43 @@ abstract class BaseZipDataSource implements DataSource { } this.initPromise = (async () => { - const entries = await this.zipReader.getEntries(); - const paths = entries.map((e: any) => normalizePath(e.filename || e.name)); - this.datasetRootPrefix = getDatasetRootPrefix(paths); - entries.forEach((entry: any) => { - const key = normalizePath(entry.filename || entry.name); + let totalBytes = 0; + let count = 0; + let datasetRootPrefix: string | null = null; + for await (const entry of this.zipReader.getEntriesGenerator({ strictness: 'strict' })) { + count += 1; + if (count > this.limits.maxArchiveEntries) { + throw new DataSourceSafetyError('ENTRY_COUNT_LIMIT', 'ZIP contains too many entries', { + count, + limit: this.limits.maxArchiveEntries, + }); + } + const rawPath = entry.filename; + const key = validateUntrustedPath(rawPath, this.limits); + if (this.entryMap.has(key)) { + throw new DataSourceSafetyError('DUPLICATE_PATH', 'ZIP contains a duplicate path', { + path: key, + }); + } + totalBytes = validateEntrySizes( + key, + Number(entry.uncompressedSize ?? 0), + entry.compressedSize == null ? undefined : Number(entry.compressedSize), + totalBytes, + this.limits, + ); + if (datasetRootPrefix === null) { + if (key === 'meta/info.json') datasetRootPrefix = ''; + else if (key.endsWith('/meta/info.json')) { + datasetRootPrefix = key.slice(0, key.length - 'meta/info.json'.length); + } + } this.entryMap.set(key, entry); - }); + } + if (datasetRootPrefix === null) { + throw new Error('Not a valid LeRobot dataset: meta/info.json not found in archive'); + } + this.datasetRootPrefix = datasetRootPrefix; })(); try { @@ -169,6 +209,7 @@ abstract class BaseZipDataSource implements DataSource { await this.ensureInitialized(); const entry = this.entryMap.get(this.resolvePath(path)); if (!entry) throw new Error(`File not found in archive: ${path}`); + assertMaterializable(path, Number(entry.uncompressedSize ?? 0), this.limits); const writer = new TextWriter(); return entry.getData(writer); } @@ -177,6 +218,7 @@ abstract class BaseZipDataSource implements DataSource { await this.ensureInitialized(); const entry = this.entryMap.get(this.resolvePath(path)); if (!entry) throw new Error(`File not found in archive: ${path}`); + assertMaterializable(path, Number(entry.uncompressedSize ?? 0), this.limits); const writer = new Uint8ArrayWriter(); return entry.getData(writer); } @@ -230,22 +272,22 @@ abstract class BaseZipDataSource implements DataSource { } export class ZipDataSourceLocal extends BaseZipDataSource { - constructor(file: File) { + constructor(file: File, limitOverrides?: Partial) { const reader = new BlobReader(file); - const zipReader = new ZipReader(reader, { useWebWorkers: false }); - super(zipReader); + const zipReader = new ZipReader(reader, { useWebWorkers: false, strictness: 'strict' }); + super(zipReader, limitOverrides); } } export class ZipDataSourceHttp extends BaseZipDataSource { - constructor(url: string) { + constructor(url: string, limitOverrides?: Partial) { const reader = new HttpReader(url, { preventHeadRequest: true, useRangeHeader: true, forceRangeRequests: true, }); - const zipReader = new ZipReader(reader, { useWebWorkers: false }); - super(zipReader); + const zipReader = new ZipReader(reader, { useWebWorkers: false, strictness: 'strict' }); + super(zipReader, limitOverrides); } } @@ -271,6 +313,14 @@ async function collectSmallResponse( signal?: AbortSignal, ): Promise { const total = Number(response.headers.get('content-length') || 0); + if (Number.isFinite(total) && total > maxBytes) { + await response.body?.cancel().catch(() => undefined); + throw new DataSourceSafetyError( + 'MATERIALIZED_SIZE_LIMIT', + `Complete archive download exceeds the ${maxBytes} byte safety limit`, + { declaredSize: total, limit: maxBytes }, + ); + } const reader = response.body?.getReader(); if (!reader) throw new Error('The server did not provide a readable response body'); const chunks: Uint8Array[] = []; @@ -282,8 +332,11 @@ async function collectSmallResponse( if (done) break; loaded += value.byteLength; if (loaded > maxBytes) { - throw new Error( - `This browser cannot store a ${loaded} byte archive safely. Enable Range requests or use a browser with OPFS support.`, + await reader.cancel().catch(() => undefined); + throw new DataSourceSafetyError( + 'MATERIALIZED_SIZE_LIMIT', + `Complete archive download exceeds the ${maxBytes} byte safety limit`, + { loaded, limit: maxBytes }, ); } chunks.push(value); @@ -309,18 +362,22 @@ async function downloadResponseToOpfs( const storage = navigator.storage as OpfsStorage; const directory = await storage.getDirectory?.(); if (!directory) { + const inMemoryLimit = Math.min(maxBytes, MAX_IN_MEMORY_FULL_ARCHIVE_BYTES); return { - file: await collectSmallResponse( - response, - MAX_IN_MEMORY_FULL_ARCHIVE_BYTES, - onProgress, - signal, - ), + file: await collectSmallResponse(response, inMemoryLimit, onProgress, signal), cleanup: async () => undefined, }; } const expected = Number(response.headers.get('content-length') || 0); + if (Number.isFinite(expected) && expected > maxBytes) { + await response.body?.cancel().catch(() => undefined); + throw new DataSourceSafetyError( + 'MATERIALIZED_SIZE_LIMIT', + `Complete archive download exceeds the ${maxBytes} byte safety limit`, + { declaredSize: expected, limit: maxBytes }, + ); + } const estimate = await navigator.storage.estimate?.(); if ( expected > 0 && @@ -348,7 +405,12 @@ async function downloadResponseToOpfs( if (done) break; loaded += value.byteLength; if (loaded > maxBytes) { - throw new Error(`Complete archive download exceeds the ${maxBytes} byte safety limit`); + await reader.cancel().catch(() => undefined); + throw new DataSourceSafetyError( + 'MATERIALIZED_SIZE_LIMIT', + `Complete archive download exceeds the ${maxBytes} byte safety limit`, + { loaded, limit: maxBytes }, + ); } await writable.write(value); onProgress?.({ @@ -668,10 +730,15 @@ interface TarEntryMeta { function parseOctal(str: string): number { const trimmed = str.replace(/\0.*$/, '').trim(); if (!trimmed) return 0; - return parseInt(trimmed, 8); + if (!/^[0-7]+$/.test(trimmed)) return Number.NaN; + return Number.parseInt(trimmed, 8); } -function parseTarHeader(block: Uint8Array, globalOffset: number): TarEntryMeta | null { +function parseTarHeader( + block: Uint8Array, + globalOffset: number, + limits: Readonly, +): TarEntryMeta | null { // 检查是否为空块 if (block.every((b) => b === 0)) return null; @@ -702,7 +769,12 @@ function parseTarHeader(block: Uint8Array, globalOffset: number): TarEntryMeta | // 'g', 'x' - PAX 扩展头(跳过) const type: 'file' | 'dir' = typeflag === 53 /* '5' */ ? 'dir' : 'file'; - return { path: normalizePath(name), offset: globalOffset + 512, size, type }; + return { + path: validateUntrustedPath(name, limits), + offset: globalOffset + 512, + size, + type, + }; } function pad512(n: number) { @@ -715,21 +787,72 @@ function toSafeBuffer(bytes: Uint8Array): ArrayBuffer { return safe.buffer; } -function concatUint8(a: Uint8Array, b: Uint8Array): Uint8Array { - const out = new Uint8Array(a.length + b.length); - out.set(a, 0); - out.set(b, a.length); - return out; -} +async function gunzipBounded( + stream: ReadableStream, + compressedSize: number | undefined, + limits: Readonly, +): Promise { + const chunks: Uint8Array[] = []; + let outputBytes = 0; + const maxOutputBytes = Math.min(limits.maxTarGzOutputBytes, limits.maxMaterializedBytes); + const append = (chunk: Uint8Array) => { + outputBytes += chunk.byteLength; + if ( + compressedSize !== undefined && + outputBytes >= limits.compressionRatioFloorBytes && + (compressedSize <= 0 || outputBytes / compressedSize > limits.maxCompressionRatio) + ) { + throw new DataSourceSafetyError( + 'COMPRESSION_RATIO_LIMIT', + 'TAR.GZ has a suspicious compression ratio', + { compressedSize, outputBytes, limit: limits.maxCompressionRatio }, + ); + } + if (outputBytes > maxOutputBytes) { + throw new DataSourceSafetyError( + 'TOTAL_SIZE_LIMIT', + 'TAR.GZ decompressed output exceeds the in-memory safety limit', + { outputBytes, limit: maxOutputBytes }, + ); + } + chunks.push(chunk); + }; -async function gunzipNative(data: ArrayBuffer): Promise { if (typeof DecompressionStream !== 'undefined') { const ds = new DecompressionStream('gzip'); - const stream = new Response(new Blob([data]).stream().pipeThrough(ds)); - const buf = await stream.arrayBuffer(); - return new Uint8Array(buf); + const reader = stream + .pipeThrough(ds as unknown as TransformStream) + .getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + append(value); + } + } else { + const inputReader = stream.getReader(); + let inflateError: Error | null = null; + const gunzip = new Gunzip((chunk) => { + try { + append(chunk); + } catch (error) { + inflateError = error as Error; + } + }); + while (true) { + const { done, value } = await inputReader.read(); + gunzip.push(value ?? new Uint8Array(0), done); + if (inflateError) throw inflateError; + if (done) break; + } + } + + const output = new Uint8Array(outputBytes); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; } - return gunzipSync(new Uint8Array(data)); + return output; } abstract class BaseTarDataSource implements DataSource { @@ -738,6 +861,34 @@ abstract class BaseTarDataSource implements DataSource { protected objectUrlLoading: Map> = new Map(); protected indexed = false; protected datasetRootPrefix = ''; + protected totalEntryBytes = 0; + protected readonly limits: Readonly; + + constructor(limitOverrides?: Partial) { + this.limits = resolveSafetyLimits(limitOverrides); + } + + protected addEntry(entry: TarEntryMeta): void { + if (this.entries.size >= this.limits.maxArchiveEntries) { + throw new DataSourceSafetyError('ENTRY_COUNT_LIMIT', 'TAR contains too many entries', { + count: this.entries.size + 1, + limit: this.limits.maxArchiveEntries, + }); + } + if (this.entries.has(entry.path)) { + throw new DataSourceSafetyError('DUPLICATE_PATH', 'TAR contains a duplicate path', { + path: entry.path, + }); + } + this.totalEntryBytes = validateEntrySizes( + entry.path, + entry.size, + undefined, + this.totalEntryBytes, + this.limits, + ); + this.entries.set(entry.path, entry); + } protected resolvePath(path: string): string { return `${this.datasetRootPrefix}${normalizePath(path)}`; @@ -811,6 +962,7 @@ abstract class BaseTarDataSource implements DataSource { this.objectUrlCache.clear(); this.objectUrlLoading.clear(); this.entries.clear(); + this.totalEntryBytes = 0; this.datasetRootPrefix = ''; this.indexed = false; } @@ -819,7 +971,7 @@ abstract class BaseTarDataSource implements DataSource { export class TarDataSourceLocal implements DataSource { private base: BaseTarDataSource; - constructor(file: File) { + constructor(file: File, limitOverrides?: Partial) { // delegate to shared base impl (composition avoids touching public type surface) this.base = new (class extends BaseTarDataSource { private outer = file; @@ -846,43 +998,27 @@ export class TarDataSourceLocal implements DataSource { this.indexPromise = (async () => { const total = this.outer.size; - // `pos` 表示已经从文件头读取到的“末端”绝对偏移(end offset) - // buffer 保存的是 [pos - buffer.length, pos) 这一段数据 let pos = 0; - let buffer: Uint8Array = new Uint8Array(0); - const chunkSize = 4 * 1024 * 1024; - - while (pos < total) { - const remain = total - pos; - const chunk = await this.readChunk(pos, Math.min(chunkSize, remain)); - buffer = concatUint8(buffer, chunk) as Uint8Array; - // 读入 chunk 后,pos 前进到新的 end offset - pos += chunk.length; - let cursor = 0; - while (buffer.length - cursor >= 512) { - const headerView = buffer.subarray(cursor, cursor + 512); - if (headerView.every((b) => b === 0)) { - pos = total; // reached end-of-archive - cursor = buffer.length; - break; - } - // 注意:buffer 可能包含上一次循环的残留数据,因此 header 的全局偏移 - // 不能用 `pos + cursor`,而应该用 `(pos - buffer.length) + cursor` - const entry = parseTarHeader(headerView, pos - buffer.length + cursor); - if (!entry) { - pos = total; - cursor = buffer.length; - break; - } - this.entries.set(entry.path, entry); - const dataSize = pad512(entry.size); - const need = 512 + dataSize; - if (buffer.length - cursor < need) { - break; // need more data - } - cursor += need; + while (pos + 512 <= total) { + const header = await this.readChunk(pos, 512); + if (header.every((b) => b === 0)) break; + const entry = parseTarHeader(header, pos, this.limits); + if (!entry) break; + this.addEntry(entry); + const next = pos + 512 + pad512(entry.size); + if (!Number.isSafeInteger(next) || next > total) { + throw new DataSourceSafetyError( + 'ARCHIVE_TRUNCATED', + 'TAR entry exceeds archive bounds', + { + path: entry.path, + offset: entry.offset, + size: entry.size, + archiveSize: total, + }, + ); } - buffer = buffer.slice(cursor); + pos = next; this.emit(onProgress, { phase: 'index', loaded: pos, total, message: 'Indexing tar' }); } this.finalizeDatasetRootPrefix(); @@ -900,11 +1036,12 @@ export class TarDataSourceLocal implements DataSource { await this.ensureIndex(onProgress); const entry = this.entries.get(this.resolvePath(path)); if (!entry) throw new Error(`File not found in tar: ${path}`); + assertMaterializable(path, entry.size, this.limits); const slice = this.outer.slice(entry.offset, entry.offset + entry.size); onProgress?.({ phase: 'read', loaded: 0, total: entry.size }); return new Uint8Array(await slice.arrayBuffer()); } - })(); + })(limitOverrides); } async exists(path: string): Promise { @@ -941,12 +1078,18 @@ class TarBufferDataSource implements DataSource { private bufferReady: Promise; private base: BaseTarDataSource; - constructor(buffer: Uint8Array | Promise) { + constructor( + buffer: Uint8Array | Promise, + limitOverrides?: Partial, + ) { + const limits = resolveSafetyLimits(limitOverrides); if (buffer instanceof Promise) { this.bufferReady = buffer.then((buf) => { + assertMaterializable('archive', buf.byteLength, limits); this.buffer = buf; }); } else { + assertMaterializable('archive', buffer.byteLength, limits); this.buffer = buffer; this.bufferReady = Promise.resolve(); } @@ -955,8 +1098,11 @@ class TarBufferDataSource implements DataSource { private outer: TarBufferDataSource; private indexPromise: Promise | null = null; - constructor(outer: TarBufferDataSource) { - super(); + constructor( + limitOverrides: Partial | undefined, + outer: TarBufferDataSource, + ) { + super(limitOverrides); this.outer = outer; } @@ -974,10 +1120,22 @@ class TarBufferDataSource implements DataSource { while (cursor + 512 <= total) { const block = this.outer.buffer.subarray(cursor, cursor + 512); if (block.every((b) => b === 0)) break; - const entry = parseTarHeader(block, cursor); + const entry = parseTarHeader(block, cursor, this.limits); if (!entry) break; - this.entries.set(entry.path, entry); + this.addEntry(entry); const step = 512 + pad512(entry.size); + if (!Number.isSafeInteger(cursor + step) || cursor + step > total) { + throw new DataSourceSafetyError( + 'ARCHIVE_TRUNCATED', + 'TAR entry exceeds archive bounds', + { + path: entry.path, + offset: entry.offset, + size: entry.size, + archiveSize: total, + }, + ); + } cursor += step; onProgress?.({ phase: 'index', loaded: cursor, total }); } @@ -996,13 +1154,14 @@ class TarBufferDataSource implements DataSource { await this.ensureIndex(onProgress); const entry = this.entries.get(this.resolvePath(path)); if (!entry) throw new Error(`File not found in tar: ${path}`); + assertMaterializable(path, entry.size, this.limits); const data = this.outer.buffer.subarray(entry.offset, entry.offset + entry.size); onProgress?.({ phase: 'read', loaded: entry.size, total: entry.size }); const copy = new Uint8Array(data.length); copy.set(data); return copy; } - })(this); + })(limitOverrides, this); } async exists(path: string): Promise { @@ -1035,41 +1194,50 @@ class TarBufferDataSource implements DataSource { } export class TarGzDataSourceLocal extends TarBufferDataSource { - constructor(file: File, onProgress?: ProgressHandler) { + constructor( + file: File, + onProgress?: ProgressHandler, + limitOverrides?: Partial, + ) { + const limits = resolveSafetyLimits(limitOverrides); const bufferPromise = (async () => { onProgress?.({ phase: 'download', loaded: 0, total: file.size, message: 'Reading tar.gz' }); - const data = new Uint8Array(await file.arrayBuffer()); - onProgress?.({ phase: 'gunzip', loaded: 0, total: data.length, message: 'Decompressing' }); - const unzipped = await gunzipNative(data.buffer); + onProgress?.({ phase: 'gunzip', loaded: 0, total: file.size, message: 'Decompressing' }); + const unzipped = await gunzipBounded(file.stream(), file.size, limits); return unzipped; })(); - super(bufferPromise); + super(bufferPromise, limitOverrides); } } export class TarGzDataSourceHttp extends TarBufferDataSource { - constructor(url: string, onProgress?: ProgressHandler) { + constructor( + url: string, + onProgress?: ProgressHandler, + limitOverrides?: Partial, + ) { + const limits = resolveSafetyLimits(limitOverrides); const bufferPromise = (async () => { const res = await fetch(url); if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`); const total = Number(res.headers.get('content-length') || 0); - const arrayBuf = await res.arrayBuffer(); + if (!res.body) throw new Error(`Failed to fetch ${url}: response body is not readable`); onProgress?.({ phase: 'download', - loaded: total || arrayBuf.byteLength, + loaded: 0, total, - message: 'Downloaded tar.gz', + message: 'Downloading tar.gz', }); onProgress?.({ phase: 'gunzip', loaded: 0, - total: arrayBuf.byteLength, + total, message: 'Decompressing', }); - const unzipped = await gunzipNative(arrayBuf); + const unzipped = await gunzipBounded(res.body, total || undefined, limits); return unzipped; })(); - super(bufferPromise); + super(bufferPromise, limitOverrides); } } @@ -1083,46 +1251,44 @@ export class TarGzDataSourceHttp extends TarBufferDataSource { * tar 格式适合小型数据集(< 100MB)。 */ export class TarDataSourceHttp extends TarBufferDataSource { - constructor(url: string, onProgress?: ProgressHandler) { + constructor( + url: string, + onProgress?: ProgressHandler, + limitOverrides?: Partial, + ) { + const limits = resolveSafetyLimits(limitOverrides); const bufferPromise = (async () => { const res = await fetch(url); if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status}`); const total = Number(res.headers.get('content-length') || 0); - - // 使用流式读取以便报告进度 - if (res.body && total > 0) { - const reader = res.body.getReader(); - const chunks: Uint8Array[] = []; - let loaded = 0; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - chunks.push(value); - loaded += value.length; - onProgress?.({ phase: 'download', loaded, total, message: 'Downloading tar...' }); - } - - // 合并所有 chunks - const result = new Uint8Array(loaded); - let offset = 0; - for (const chunk of chunks) { - result.set(chunk, offset); - offset += chunk.length; + assertMaterializable('remote tar', total || undefined, limits); + if (!res.body) throw new Error(`Failed to fetch ${url}: response body is not readable`); + const reader = res.body.getReader(); + const chunks: Uint8Array[] = []; + let loaded = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + loaded += value.byteLength; + if (loaded > limits.maxMaterializedBytes) { + await reader.cancel(); + throw new DataSourceSafetyError( + 'MATERIALIZED_SIZE_LIMIT', + 'Remote TAR exceeded the in-memory size limit while downloading', + { loaded, limit: limits.maxMaterializedBytes }, + ); } - return result; - } else { - // 降级:一次性读取 - const arrayBuf = await res.arrayBuffer(); - onProgress?.({ - phase: 'download', - loaded: arrayBuf.byteLength, - total: arrayBuf.byteLength, - message: 'Downloaded tar', - }); - return new Uint8Array(arrayBuf); + chunks.push(value); + onProgress?.({ phase: 'download', loaded, total, message: 'Downloading tar...' }); + } + const result = new Uint8Array(loaded); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.length; } + return result; })(); - super(bufferPromise); + super(bufferPromise, limitOverrides); } } diff --git a/src/platform/datasource/inputSafety.ts b/src/platform/datasource/inputSafety.ts new file mode 100644 index 0000000..3acde68 --- /dev/null +++ b/src/platform/datasource/inputSafety.ts @@ -0,0 +1,185 @@ +export interface DataSourceSafetyLimits { + maxArchiveEntries: number; + maxArchiveEntryBytes: number; + maxArchiveTotalBytes: number; + maxMaterializedBytes: number; + maxPathLength: number; + maxPathDepth: number; + maxCompressionRatio: number; + compressionRatioFloorBytes: number; + maxTarGzOutputBytes: number; + maxManifestEntries: number; + maxManifestEntryBytes: number; + maxManifestTotalBytes: number; + maxManifestUrlLength: number; +} + +export const DEFAULT_DATA_SOURCE_SAFETY_LIMITS: Readonly = Object.freeze({ + maxArchiveEntries: 250_000, + maxArchiveEntryBytes: 16 * 1024 ** 3, + maxArchiveTotalBytes: 4 * 1024 ** 4, + maxMaterializedBytes: 512 * 1024 ** 2, + maxPathLength: 1024, + maxPathDepth: 64, + maxCompressionRatio: 1000, + compressionRatioFloorBytes: 1024 * 1024, + maxTarGzOutputBytes: 512 * 1024 ** 2, + maxManifestEntries: 250_000, + maxManifestEntryBytes: 64 * 1024 ** 3, + maxManifestTotalBytes: 16 * 1024 ** 4, + maxManifestUrlLength: 16 * 1024, +}); + +export type DataSourceSafetyErrorCode = + | 'ENTRY_COUNT_LIMIT' + | 'ENTRY_SIZE_LIMIT' + | 'TOTAL_SIZE_LIMIT' + | 'MATERIALIZED_SIZE_LIMIT' + | 'PATH_INVALID' + | 'PATH_LENGTH_LIMIT' + | 'PATH_DEPTH_LIMIT' + | 'DUPLICATE_PATH' + | 'COMPRESSION_RATIO_LIMIT' + | 'ARCHIVE_TRUNCATED' + | 'MANIFEST_INVALID' + | 'URL_INVALID'; + +export class DataSourceSafetyError extends Error { + readonly name = 'DataSourceSafetyError'; + readonly code: DataSourceSafetyErrorCode; + readonly details: Readonly>; + + constructor( + code: DataSourceSafetyErrorCode, + message: string, + details: Readonly> = {}, + ) { + super(message); + this.code = code; + this.details = details; + } +} + +export function resolveSafetyLimits( + overrides?: Partial, +): Readonly { + const limits = { ...DEFAULT_DATA_SOURCE_SAFETY_LIMITS, ...overrides }; + for (const [name, value] of Object.entries(limits)) { + if (!Number.isFinite(value) || value <= 0) { + throw new TypeError(`Invalid data-source safety limit ${name}: ${value}`); + } + } + return limits; +} + +export function validateUntrustedPath( + rawPath: string, + limits: Readonly, +): string { + if ( + !rawPath || + rawPath.includes('\0') || + rawPath.startsWith('/') || + rawPath.startsWith('\\') || + /^[a-zA-Z]:[\\/]/.test(rawPath) + ) { + throw new DataSourceSafetyError('PATH_INVALID', 'Archive path is empty or absolute', { + path: rawPath, + }); + } + + const normalized = rawPath + .replace(/\\/g, '/') + .replace(/^\.\/+/, '') + .replace(/\/{2,}/g, '/'); + const segments = normalized.split('/'); + if (!normalized || segments.every((segment) => !segment || segment === '.')) { + throw new DataSourceSafetyError('PATH_INVALID', 'Archive path is empty after normalization', { + path: rawPath, + }); + } + if (segments.some((segment) => segment === '..')) { + throw new DataSourceSafetyError('PATH_INVALID', 'Archive path attempts to escape its root', { + path: rawPath, + }); + } + if (normalized.length > limits.maxPathLength) { + throw new DataSourceSafetyError('PATH_LENGTH_LIMIT', 'Archive path is too long', { + pathLength: normalized.length, + limit: limits.maxPathLength, + }); + } + const depth = segments.filter((segment) => segment && segment !== '.').length; + if (depth > limits.maxPathDepth) { + throw new DataSourceSafetyError('PATH_DEPTH_LIMIT', 'Archive path is too deep', { + depth, + limit: limits.maxPathDepth, + }); + } + return normalized; +} + +export function validateEntrySizes( + path: string, + uncompressedSize: number, + compressedSize: number | undefined, + currentTotal: number, + limits: Readonly, +): number { + if (!Number.isSafeInteger(uncompressedSize) || uncompressedSize < 0) { + throw new DataSourceSafetyError('ENTRY_SIZE_LIMIT', 'Archive entry has an invalid size', { + path, + size: uncompressedSize, + }); + } + if ( + compressedSize !== undefined && + (!Number.isSafeInteger(compressedSize) || compressedSize < 0) + ) { + throw new DataSourceSafetyError( + 'ENTRY_SIZE_LIMIT', + 'Archive entry has an invalid compressed size', + { path, compressedSize }, + ); + } + if (uncompressedSize > limits.maxArchiveEntryBytes) { + throw new DataSourceSafetyError('ENTRY_SIZE_LIMIT', 'Archive entry exceeds the size limit', { + path, + size: uncompressedSize, + limit: limits.maxArchiveEntryBytes, + }); + } + const total = currentTotal + uncompressedSize; + if (!Number.isSafeInteger(total) || total > limits.maxArchiveTotalBytes) { + throw new DataSourceSafetyError('TOTAL_SIZE_LIMIT', 'Archive exceeds the total size limit', { + total, + limit: limits.maxArchiveTotalBytes, + }); + } + if ( + compressedSize !== undefined && + uncompressedSize >= limits.compressionRatioFloorBytes && + (compressedSize <= 0 || uncompressedSize / compressedSize > limits.maxCompressionRatio) + ) { + throw new DataSourceSafetyError( + 'COMPRESSION_RATIO_LIMIT', + 'Archive entry has a suspicious compression ratio', + { path, compressedSize, uncompressedSize, limit: limits.maxCompressionRatio }, + ); + } + return total; +} + +export function assertMaterializable( + path: string, + size: number | undefined, + limits: Readonly, +): void { + if (size !== undefined && size > limits.maxMaterializedBytes) { + throw new DataSourceSafetyError( + 'MATERIALIZED_SIZE_LIMIT', + 'File is too large to materialize safely in memory', + { path, size, limit: limits.maxMaterializedBytes }, + ); + } +} diff --git a/src/platform/datasource/remotePreflight.ts b/src/platform/datasource/remotePreflight.ts index 6a5eeb9..2533837 100644 --- a/src/platform/datasource/remotePreflight.ts +++ b/src/platform/datasource/remotePreflight.ts @@ -2,6 +2,7 @@ import type { ArchiveKind } from './ArchiveDataSourceFactory'; import { getArchiveKindFromHeaders, getArchiveKindFromMagicBytes, + getArchiveKindFromUrl, } from './ArchiveDataSourceFactory'; export type RemotePreflightFailureCode = @@ -113,6 +114,31 @@ async function readPrefix(response: Response, maxBytes: number): Promise BigInt(newEpisodeIndex)); + const index = Array.from({ length: n }, (_, i) => BigInt(globalStart + i)); - const overrides: Record = {}; + const overrides: Record = {}; if (table.schema.fields.some((f) => f.name === 'episode_index')) { - overrides.episode_index = episodeIndex; + overrides.episode_index = arrow.vectorFromArray(episodeIndex, new arrow.Int64()); } if (table.schema.fields.some((f) => f.name === 'index')) { - overrides.index = index; + overrides.index = arrow.vectorFromArray(index, new arrow.Int64()); + } + const sourceTaskIndex = table.getChild('task_index'); + if (sourceTaskIndex) { + const rewritten = new Array(n); + for (let row = 0; row < n; row++) { + const sourceIndex = toSafeInteger(sourceTaskIndex.get(row), 'task_index'); + const targetIndex = resolveExportTaskIndex(taskPlan, newEpisodeIndex, sourceIndex); + if (targetIndex === undefined) { + throw new Error( + `Cannot map frame task_index ${sourceIndex} for exported episode ${newEpisodeIndex}`, + ); + } + rewritten[row] = BigInt(targetIndex); + } + overrides.task_index = arrow.vectorFromArray(rewritten, new arrow.Int64()); } if (Object.keys(overrides).length === 0) return table; - const overrideTable = arrow.tableFromArrays(overrides); + const overrideTable = new arrow.Table(overrides as never); return table.assign(overrideTable) as unknown as Table; } +async function getValidatedEpisodeTable( + dataLoader: LeRobotDataLoader, + episode: EpisodeMetadata, +): Promise { + const { table } = await dataLoader.getEpisodeTableForExport(episode.episode_index); + if (!Number.isSafeInteger(episode.length) || episode.length < 0) { + throw new Error( + `Episode ${episode.episode_index} has invalid metadata length ${episode.length}`, + ); + } + if (table.numRows !== episode.length) { + throw new Error( + `Episode ${episode.episode_index} row count ${table.numRows} does not match metadata length ${episode.length}`, + ); + } + return table; +} + export interface ExportDataOptions { /** Columns to strip from the exported parquet files (e.g. image features replaced by video). */ excludeColumns?: Set; + /** Source task mapping used to rewrite frame-level task_index values. */ + tasks?: Record; + /** Precomputed canonical plan shared with stats and metadata preparation. */ + taskPlan?: ExportTaskPlan; } /** @@ -60,23 +113,27 @@ export async function exportDataFiles( onProgress?: (p: ExportProgress) => void, signal?: AbortSignal, options?: ExportDataOptions, -): Promise { +): Promise { if (signal?.aborted) throw new DOMException('Export cancelled', 'AbortError'); - const isTargetV3 = targetVersion?.startsWith('v3') ?? info.codebase_version.startsWith('v3'); - const isSourceV3 = info.codebase_version.startsWith('v3'); + const isTargetV3 = targetVersion !== undefined ? targetVersion === 'v3.0' : isV3Info(info); + const isSourceV3 = isV3Info(info); + const taskPlan = options?.taskPlan ?? buildExportTaskPlan(episodes, options?.tasks ?? {}); const total = episodes.length; - if (total === 0) return; + if (total === 0) { + return isTargetV3 ? { episodes: new Map(), total_chunks: 0, total_files: 0 } : undefined; + } if (isTargetV3 && !isSourceV3) { - await mergeV2ToV3(dataLoader, info, episodes, adapter, onProgress, signal, options); + return mergeV2ToV3(dataLoader, info, episodes, adapter, taskPlan, onProgress, signal, options); } else if (!isTargetV3 && isSourceV3) { - await splitV3ToV2(dataLoader, episodes, adapter, onProgress, signal, options); + await splitV3ToV2(dataLoader, episodes, adapter, taskPlan, onProgress, signal, options); } else { - await filterAndWriteSameVersion( + return filterAndWriteSameVersion( dataLoader, info, episodes, adapter, + taskPlan, onProgress, signal, options, @@ -96,58 +153,31 @@ async function filterAndWriteSameVersion( info: LeRobotInfo, episodes: EpisodeMetadata[], adapter: ExportAdapter, + taskPlan: ExportTaskPlan, onProgress?: (p: ExportProgress) => void, signal?: AbortSignal, options?: ExportDataOptions, -): Promise { +): Promise { const total = episodes.length; if (isV3Info(info)) { - // Output is packed into new 0-based v3 layout: one file per `chunks_size` - // block of exported episodes (data/chunk-XXX/file-000.parquet). This makes - // the exported dataset self-contained and resilient to the user deleting - // episodes from arbitrary source chunks. - const chunksSize = info.chunks_size ?? CHUNK_SIZE_DEFAULT; - const chunkToIndices = new Map(); - for (let i = 0; i < episodes.length; i++) { - const chunkIdx = Math.floor(i / chunksSize); - if (!chunkToIndices.has(chunkIdx)) chunkToIndices.set(chunkIdx, []); - chunkToIndices.get(chunkIdx)!.push(i); - } - let globalRow = 0; - let done = 0; - for (const [chunkIdx, indices] of chunkToIndices.entries()) { - if (signal?.aborted) throw new DOMException('Export cancelled', 'AbortError'); - const tables: Table[] = []; - for (const i of indices) { - const ep = episodes[i]; - const { table } = await dataLoader.getEpisodeTableForExport(ep.episode_index); - const stripped = dropColumns(table, options?.excludeColumns); - const reindexed = rewriteEpisodeAndGlobalIndex(stripped, i, globalRow); - globalRow += stripped.numRows; - tables.push(reindexed); - } - const merged = concatTables(tables); - const outPath = `data/chunk-${String(chunkIdx).padStart(3, '0')}/file-000.parquet`; - await ensureParentDir(adapter, outPath); - const bytes = await tableToParquetBytes(merged); - adapter.writeFile(outPath, bytes); - done += indices.length; - onProgress?.({ - phase: 'data', - current: done, - total, - message: `Data chunk ${chunkIdx}`, - cancelable: false, - }); - } + return writeV3DataFiles( + dataLoader, + info, + episodes, + adapter, + taskPlan, + onProgress, + signal, + options, + ); } else { let globalRow = 0; for (let i = 0; i < episodes.length; i++) { if (signal?.aborted) throw new DOMException('Export cancelled', 'AbortError'); const ep = episodes[i]; - const { table } = await dataLoader.getEpisodeTableForExport(ep.episode_index); + const table = await getValidatedEpisodeTable(dataLoader, ep); const stripped = dropColumns(table, options?.excludeColumns); - const reindexed = rewriteEpisodeAndGlobalIndex(stripped, i, globalRow); + const reindexed = rewriteEpisodeAndGlobalIndex(stripped, i, globalRow, taskPlan); globalRow += stripped.numRows; const chunkIdx = Math.floor(i / (info.chunks_size ?? CHUNK_SIZE_DEFAULT)); const outPath = `data/chunk-${String(chunkIdx).padStart(3, '0')}/episode_${String(i).padStart(6, '0')}.parquet`; @@ -163,6 +193,7 @@ async function filterAndWriteSameVersion( }); } } + return undefined; } async function mergeV2ToV3( @@ -170,53 +201,140 @@ async function mergeV2ToV3( info: LeRobotInfo, episodes: EpisodeMetadata[], adapter: ExportAdapter, + taskPlan: ExportTaskPlan, onProgress?: (p: ExportProgress) => void, signal?: AbortSignal, options?: ExportDataOptions, -): Promise { - const chunksSize = info.chunks_size ?? CHUNK_SIZE_DEFAULT; - // Group exported episodes by NEW 0-based position into chunks. This guarantees - // the output layout is self-contained regardless of the source episode_index - // values. - const chunkToIndices = new Map(); - for (let i = 0; i < episodes.length; i++) { - const chunkIdx = Math.floor(i / chunksSize); - if (!chunkToIndices.has(chunkIdx)) chunkToIndices.set(chunkIdx, []); - chunkToIndices.get(chunkIdx)!.push(i); +): Promise { + return writeV3DataFiles( + dataLoader, + info, + episodes, + adapter, + taskPlan, + onProgress, + signal, + options, + ); +} + +function positiveIntegerOrDefault(value: unknown, fallback: number, field: string): number { + if (value === undefined) return fallback; + if (!Number.isSafeInteger(value) || Number(value) <= 0) { + throw new Error(`${field} must be a positive integer`); + } + return Number(value); +} + +function positiveNumberOrDefault(value: unknown, fallback: number, field: string): number { + if (value === undefined) return fallback; + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + throw new Error(`${field} must be a positive number`); } - let done = 0; + return value; +} + +/** + * Write whole episodes into size-bounded v3 Parquet files. + * + * Official LeRobot estimates the next episode's uncompressed contribution from + * the current file's average bytes/frame before rotating. In the browser we + * already have the complete Arrow tables, so serializing the candidate file + * gives a deterministic and more accurate equivalent. Episodes remain atomic; + * an individual oversized episode is written as one oversized file. + */ +async function writeV3DataFiles( + dataLoader: LeRobotDataLoader, + info: LeRobotInfo, + episodes: EpisodeMetadata[], + adapter: ExportAdapter, + taskPlan: ExportTaskPlan, + onProgress?: (p: ExportProgress) => void, + signal?: AbortSignal, + options?: ExportDataOptions, +): Promise { + const chunksSize = positiveIntegerOrDefault(info.chunks_size, CHUNK_SIZE_DEFAULT, 'chunks_size'); + const sizeLimitBytes = + positiveNumberOrDefault( + (info as { data_files_size_in_mb?: number }).data_files_size_in_mb, + DATA_FILE_SIZE_MB_DEFAULT, + 'data_files_size_in_mb', + ) * BYTES_PER_MB; + const locations: V3DataLayout['episodes'] = new Map(); + let chunkIndex = 0; + let fileIndex = 0; let globalRow = 0; - for (const [chunkIdx, indices] of chunkToIndices.entries()) { + let fileTables: Table[] = []; + let fileBytes: Uint8Array | undefined; + let totalFiles = 0; + + const flush = async (): Promise => { + if (fileTables.length === 0 || !fileBytes) return; + const outPath = `data/chunk-${String(chunkIndex).padStart(3, '0')}/file-${String(fileIndex).padStart(3, '0')}.parquet`; + await ensureParentDir(adapter, outPath); + await adapter.writeFile(outPath, fileBytes); + totalFiles++; + fileTables = []; + fileBytes = undefined; + }; + + const advanceFile = (): void => { + if (fileIndex + 1 >= chunksSize) { + chunkIndex++; + fileIndex = 0; + } else { + fileIndex++; + } + }; + + for (let outputEpisodeIndex = 0; outputEpisodeIndex < episodes.length; outputEpisodeIndex++) { if (signal?.aborted) throw new DOMException('Export cancelled', 'AbortError'); - const tables: Table[] = []; - for (const i of indices) { - const ep = episodes[i]; - const { table } = await dataLoader.getEpisodeTableForExport(ep.episode_index); - const stripped = dropColumns(table, options?.excludeColumns); - const reindexed = rewriteEpisodeAndGlobalIndex(stripped, i, globalRow); - globalRow += stripped.numRows; - tables.push(reindexed); + const episode = episodes[outputEpisodeIndex]; + const sourceTable = await getValidatedEpisodeTable(dataLoader, episode); + const stripped = dropColumns(sourceTable, options?.excludeColumns); + const table = rewriteEpisodeAndGlobalIndex(stripped, outputEpisodeIndex, globalRow, taskPlan); + let candidateTables = [...fileTables, table]; + let candidateBytes = await tableToParquetBytes(concatTables(candidateTables)); + + // Match the official >= boundary, but never emit an empty file. + if (fileTables.length > 0 && candidateBytes.byteLength >= sizeLimitBytes) { + await flush(); + advanceFile(); + candidateTables = [table]; + candidateBytes = await tableToParquetBytes(table); } - const merged = concatTables(tables); - const bytes = await tableToParquetBytes(merged); - const outPath = `data/chunk-${String(chunkIdx).padStart(3, '0')}/file-000.parquet`; - await ensureParentDir(adapter, outPath); - adapter.writeFile(outPath, bytes); - done += indices.length; + + locations.set(episode.episode_index, { + chunk_index: chunkIndex, + file_index: fileIndex, + dataset_from_index: globalRow, + dataset_to_index: globalRow + table.numRows, + }); + globalRow += table.numRows; + fileTables = candidateTables; + fileBytes = candidateBytes; onProgress?.({ phase: 'data', - current: done, + current: outputEpisodeIndex + 1, total: episodes.length, - message: `Data chunk ${chunkIdx}`, + message: `Data chunk ${chunkIndex}, file ${fileIndex}`, cancelable: false, }); } + await flush(); + + return { + episodes: locations, + total_files: totalFiles, + total_chunks: totalFiles === 0 ? 0 : chunkIndex + 1, + }; } async function splitV3ToV2( dataLoader: LeRobotDataLoader, episodes: EpisodeMetadata[], adapter: ExportAdapter, + taskPlan: ExportTaskPlan, onProgress?: (p: ExportProgress) => void, signal?: AbortSignal, options?: ExportDataOptions, @@ -227,9 +345,9 @@ async function splitV3ToV2( if (signal?.aborted) throw new DOMException('Export cancelled', 'AbortError'); const ep = episodes[i]; if (!isV3Metadata(ep)) continue; - const { table } = await dataLoader.getEpisodeTableForExport(ep.episode_index); + const table = await getValidatedEpisodeTable(dataLoader, ep); const stripped = dropColumns(table, options?.excludeColumns); - const reindexed = rewriteEpisodeAndGlobalIndex(stripped, i, globalRow); + const reindexed = rewriteEpisodeAndGlobalIndex(stripped, i, globalRow, taskPlan); globalRow += stripped.numRows; const chunkIdx = Math.floor(i / chunksSize); const outPath = `data/chunk-${String(chunkIdx).padStart(3, '0')}/episode_${String(i).padStart(6, '0')}.parquet`; diff --git a/src/platform/export/ExportService.ts b/src/platform/export/ExportService.ts index 03d30ce..771884f 100644 --- a/src/platform/export/ExportService.ts +++ b/src/platform/export/ExportService.ts @@ -1,8 +1,14 @@ import type { LeRobotDataLoader } from '../services/LeRobotDataLoader'; import type { LeRobotInfo, EpisodeMetadata } from '@/core'; import type { ExportAdapter } from '@/core'; -import type { ExportOptions, TargetVersion, ExportProgress, EpisodeVideoOffsets } from '@/core'; -import { writeMetadata } from './MetadataExporter'; +import type { + ExportOptions, + TargetVersion, + ExportProgress, + EpisodeVideoOffsets, + V3DataLayout, +} from '@/core'; +import { validateMetadataForExport, writeMetadata } from './MetadataExporter'; import { exportDataFiles } from './DataProcessor'; import { exportVideosByTarget } from './VideoExporter'; import { @@ -10,8 +16,14 @@ import { getImageFeatureKeys, rewriteFeaturesForImageToVideo, } from './ImageVideoExporter'; -import { computeDatasetStats, type DatasetStats } from '@/core'; +import { + classifyLeRobotVersion, + computeDatasetStats, + isSupportedLeRobotVersion, + type DatasetStats, +} from '@/core'; import { computeSplits, splitsIndicesToInfoSplits } from '@/core'; +import { buildExportTaskPlan, resolveExportTaskIndex } from './TaskPlan'; export class ExportService { private dataLoader: LeRobotDataLoader; @@ -21,14 +33,38 @@ export class ExportService { this.adapter = adapter; } + private assertExportAllowed(info: LeRobotInfo, targetVersion?: string): void { + const infoCapability = classifyLeRobotVersion(info.codebase_version); + const loaderCapability = this.dataLoader.getVersionCapability(); + if (loaderCapability.status !== 'supported') { + throw new Error( + `Export is disabled for loaded LeRobot ${String(loaderCapability.normalizedVersion)} (${loaderCapability.status})`, + ); + } + if (infoCapability.status !== 'supported') { + throw new Error( + `Export is disabled for LeRobot ${String(info.codebase_version)} (${infoCapability.status})`, + ); + } + if (loaderCapability.normalizedVersion !== infoCapability.normalizedVersion) { + throw new Error( + `Export info version ${String(infoCapability.normalizedVersion)} does not match loaded version ${String(loaderCapability.normalizedVersion)}`, + ); + } + if (targetVersion !== undefined && !isSupportedLeRobotVersion(targetVersion)) { + throw new Error(`Unsupported LeRobot export target: ${targetVersion}`); + } + } + async exportMetadataOnly( info: LeRobotInfo, episodes: EpisodeMetadata[], tasks: Record, options: Pick, ): Promise { - this.adapter.clear(); const targetVersion = options.targetVersion as TargetVersion | undefined; + this.assertExportAllowed(info, targetVersion); + this.adapter.clear(); const splits = options.splitsConfig && episodes.length > 0 ? splitsIndicesToInfoSplits(computeSplits(episodes, options.splitsConfig)) @@ -96,10 +132,11 @@ export class ExportService { }; throwIfAborted(); - this.adapter.clear(); + this.assertExportAllowed(info, options.targetVersion); const targetVersion = (options.targetVersion ?? 'v2.1') as TargetVersion; - let episodesForMeta = episodes; + const episodesForMeta = episodes; let videoOffsets: EpisodeVideoOffsets | null = null; + let dataLayout: V3DataLayout | undefined; const videoOptions = { signal }; // If source has dtype: 'image' features, re-encode them into MP4 videos @@ -107,10 +144,66 @@ export class ExportService { // The image columns are stripped from the exported parquet so the output // dataset does not duplicate the image payload. const imageFeatureKeys = options.includeVideos ? getImageFeatureKeys(info) : []; - let infoForExport = info; + const infoForExport = + imageFeatureKeys.length > 0 ? rewriteFeaturesForImageToVideo(info, imageFeatureKeys) : info; const columnsToExclude = new Set(imageFeatureKeys); let imageOffsets: EpisodeVideoOffsets | null = null; + const taskPlan = buildExportTaskPlan(episodesForMeta, tasks); + const splits = + options.splitsConfig && episodesForMeta.length > 0 + ? splitsIndicesToInfoSplits(computeSplits(episodesForMeta, options.splitsConfig)) + : undefined; + let stats: DatasetStats | undefined; + if (options.includeData && episodesForMeta.length > 0) { + reportPhaseStart(1, 'Validating training statistics...', 'metadata'); + stats = await computeDatasetStats(this.dataLoader, infoForExport, episodesForMeta, { + signal, + resolveNumericRow: (featureKey, context) => { + if (featureKey === 'index') return context.outputGlobalIndex; + if (featureKey === 'episode_index') return context.outputEpisodeIndex; + if (featureKey !== 'task_index') return undefined; + const sourceTaskIndex = context.sourceValues[0]; + if (!Number.isSafeInteger(sourceTaskIndex)) { + throw new Error( + `Cannot map non-integer frame task_index ${String(sourceTaskIndex)} ` + + `for exported episode ${context.outputEpisodeIndex}`, + ); + } + const targetTaskIndex = resolveExportTaskIndex( + taskPlan, + context.outputEpisodeIndex, + sourceTaskIndex, + ); + if (targetTaskIndex === undefined) { + throw new Error( + `Cannot map frame task_index ${sourceTaskIndex} ` + + `for exported episode ${context.outputEpisodeIndex}`, + ); + } + return targetTaskIndex; + }, + onProgress: (current, total) => { + const ratio = total > 0 ? current / total : 0; + onProg?.({ + phase: 'metadata', + current, + total, + message: 'Validating training statistics...', + cancelable: true, + percent: 1 + ratio * 2, + }); + }, + }); + throwIfAborted(); + } + validateMetadataForExport(infoForExport, episodesForMeta, targetVersion, splits); + throwIfAborted(); + + // All training and metadata validation has succeeded. Mutating the target + // is now safe: subsequent failures are I/O/encoding failures, not bad input. + this.adapter.clear(); + if (imageFeatureKeys.length > 0) { reportPhaseStart(3, 'Encoding image features as MP4...', 'videos'); throwIfAborted(); @@ -124,7 +217,6 @@ export class ExportService { { signal }, ); imageOffsets = offsets; - infoForExport = rewriteFeaturesForImageToVideo(info, imageFeatureKeys); } if (options.includeVideos && targetVersion === 'v3.0') { @@ -146,7 +238,6 @@ export class ExportService { Object.assign(videoOffsets.get(epIdx)!, entry); } } - episodesForMeta = episodes; } else if (options.includeVideos && targetVersion === 'v2.1') { reportPhaseStart(30, 'Preparing video export (v2.1)...', 'videos'); throwIfAborted(); @@ -163,63 +254,40 @@ export class ExportService { throwIfAborted(); - let stats: DatasetStats | undefined; - if (options.includeData && episodesForMeta.length > 0) { - reportPhaseStart(50, 'Computing dataset stats...', 'metadata'); + if (options.includeData) { + reportPhaseStart(53, 'Exporting data (Parquet)...', 'data'); throwIfAborted(); - stats = await computeDatasetStats(this.dataLoader, info, episodesForMeta, { + dataLayout = await exportDataFiles( + this.dataLoader, + infoForExport, + episodesForMeta, + targetVersion, + this.adapter, + onProg ? wrap(53, 86) : undefined, signal, - onProgress: (current, total) => { - const ratio = total > 0 ? current / total : 0; - onProg?.({ - phase: 'metadata', - current, - total, - message: 'Computing stats...', - cancelable: true, - percent: 50 + ratio * 3, - }); + { + ...(columnsToExclude.size > 0 ? { excludeColumns: columnsToExclude } : {}), + tasks, + taskPlan, }, - }); - throwIfAborted(); - } - - let splits: Record | undefined; - if (options.splitsConfig && episodesForMeta.length > 0) { - const indices = computeSplits(episodesForMeta, options.splitsConfig); - splits = splitsIndicesToInfoSplits(indices); + ); } - - reportPhaseStart(53, 'Writing metadata...', 'metadata'); throwIfAborted(); + reportPhaseStart(86, 'Writing metadata...', 'metadata'); await writeMetadata( infoForExport, episodesForMeta, tasks, targetVersion, this.adapter, - onProg ? wrap(53, 55) : undefined, + onProg ? wrap(86, 88) : undefined, videoOffsets ?? undefined, signal, stats, splits, + dataLayout, ); throwIfAborted(); - if (options.includeData) { - reportPhaseStart(55, 'Exporting data (Parquet)...', 'data'); - throwIfAborted(); - await exportDataFiles( - this.dataLoader, - infoForExport, - episodesForMeta, - targetVersion, - this.adapter, - onProg ? wrap(55, 88) : undefined, - signal, - columnsToExclude.size > 0 ? { excludeColumns: columnsToExclude } : undefined, - ); - } - throwIfAborted(); onProg?.({ phase: 'packaging', current: 1, diff --git a/src/platform/export/MetadataExporter.ts b/src/platform/export/MetadataExporter.ts index fc6ec76..7a6a359 100644 --- a/src/platform/export/MetadataExporter.ts +++ b/src/platform/export/MetadataExporter.ts @@ -1,12 +1,195 @@ import * as arrow from 'apache-arrow'; import type { LeRobotInfo, EpisodeMetadata } from '@/core'; import type { ExportAdapter } from '@/core'; -import type { ExportProgress, EpisodeVideoOffsets } from '@/core'; +import type { ExportProgress, EpisodeVideoOffsets, V3DataLayout } from '@/core'; import type { DatasetStats } from '@/core'; +import { isV3Info } from '../../core/types/lerobot'; +import { buildExportTaskPlan, type ExportTaskPlan } from './TaskPlan'; const TEXT_ENCODER = new TextEncoder(); const V3_DATA_PATH = 'data/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet'; const V3_VIDEO_PATH = 'videos/{video_key}/chunk-{chunk_index:03d}/file-{file_index:03d}.mp4'; +const V3_CHUNKS_SIZE_DEFAULT = 1000; +const V3_DATA_FILE_SIZE_MB_DEFAULT = 100; +const V3_VIDEO_FILE_SIZE_MB_DEFAULT = 200; + +type SupportedEpisodeScalar = string | number | boolean; + +function getV3RewrittenColumns(info?: LeRobotInfo): Set { + const columns = new Set([ + 'episode_index', + 'length', + 'tasks', + 'task_index', + 'dataset_from_index', + 'dataset_to_index', + 'chunk_index', + 'file_index', + 'meta/episodes/chunk_index', + 'meta/episodes/file_index', + 'data/chunk_index', + 'data/file_index', + ]); + for (const [key, feature] of Object.entries(info?.features ?? {})) { + if (feature?.dtype !== 'video') continue; + columns.add(`videos/${key}/chunk_index`); + columns.add(`videos/${key}/file_index`); + columns.add(`videos/${key}/from_timestamp`); + columns.add(`videos/${key}/to_timestamp`); + } + return columns; +} + +function validateEpisodeScalar(column: string, value: unknown): SupportedEpisodeScalar { + if (typeof value === 'number') { + if (!Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value))) { + throw new Error( + `Cannot export v3 episode metadata column "${column}": number ${String(value)} is not safely serializable.`, + ); + } + return value; + } + if (typeof value === 'string' || typeof value === 'boolean') return value; + throw new Error( + `Cannot export v3 episode metadata column "${column}": unsupported value type ${Object.prototype.toString.call(value)}.`, + ); +} + +function validatePreservedEpisodeColumn(column: string, values: unknown[]): void { + let columnKind: 'scalar' | 'array' | undefined; + let scalarKind: 'string' | 'number' | 'boolean' | undefined; + let hasTypedValue = false; + + for (const value of values) { + if (value == null) continue; + const kind = Array.isArray(value) ? 'array' : 'scalar'; + if (columnKind !== undefined && kind !== columnKind) { + throw new Error( + `Cannot export v3 episode metadata column "${column}": scalar and array values cannot share one Parquet column.`, + ); + } + columnKind = kind; + const items: unknown[] = []; + const collectItems = (item: unknown): void => { + if (Array.isArray(item)) item.forEach(collectItems); + else items.push(item); + }; + collectItems(value); + for (const item of items) { + if (item == null) continue; + const scalar = validateEpisodeScalar(column, item); + const itemKind: 'string' | 'number' | 'boolean' = + typeof scalar === 'string' ? 'string' : typeof scalar === 'number' ? 'number' : 'boolean'; + if (scalarKind !== undefined && itemKind !== scalarKind) { + throw new Error( + `Cannot export v3 episode metadata column "${column}": mixed ${scalarKind} and ${itemKind} values are not supported.`, + ); + } + scalarKind = itemKind; + hasTypedValue = true; + } + } + + if (!hasTypedValue) { + throw new Error( + `Cannot export v3 episode metadata column "${column}": its type cannot be inferred from only null or empty values.`, + ); + } +} + +function collectPreservedV3EpisodeColumns( + episodes: EpisodeMetadata[], + rewrittenColumns: Set, +): Record { + const preserved: Record = {}; + const extraKeys = new Set(episodes.flatMap((episode) => Object.keys(episode))); + for (const key of extraKeys) { + if (rewrittenColumns.has(key)) continue; + const values = episodes.map((episode) => (episode as Record)[key] ?? null); + validatePreservedEpisodeColumn(key, values); + preserved[key] = values; + } + return preserved; +} + +function createPreservedEpisodeVector(values: unknown[]): arrow.Vector { + const nonNullValue = values.find((value) => value != null); + const isArray = Array.isArray(nonNullValue); + const items: unknown[] = []; + const collectItems = (value: unknown): void => { + if (value == null) return; + if (Array.isArray(value)) value.forEach(collectItems); + else items.push(value); + }; + values.forEach(collectItems); + const sample = items[0] as SupportedEpisodeScalar; + let itemType: arrow.DataType; + let normalizedValues = values; + + if (typeof sample === 'string') { + itemType = new arrow.Utf8(); + } else if (typeof sample === 'boolean') { + itemType = new arrow.Bool(); + } else { + const integerValues = items.every((item) => typeof item === 'number' && Number.isInteger(item)); + itemType = integerValues ? new arrow.Int64() : new arrow.Float64(); + if (integerValues) { + const normalizeInteger = (value: unknown): unknown => + Array.isArray(value) + ? value.map(normalizeInteger) + : value == null + ? null + : BigInt(value as number); + normalizedValues = values.map(normalizeInteger); + } + } + + let type = itemType; + if (isArray) { + let current: unknown = nonNullValue; + while (Array.isArray(current)) { + type = new arrow.List(new arrow.Field('item', type, true)); + current = current[0]; + } + } + return arrow.vectorFromArray(normalizedValues, type); +} + +function validateV3Splits(splits: Record | undefined, episodeCount: number): void { + for (const [name, range] of Object.entries(splits ?? {})) { + const match = /^(\d+):(\d+)$/.exec(range); + if (!match) { + throw new Error( + `Cannot export split "${name}": LeRobot v3 requires one continuous start:end range, got "${range}".`, + ); + } + const start = Number(match[1]); + const end = Number(match[2]); + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + start >= end || + end > episodeCount + ) { + throw new Error( + `Cannot export split "${name}": range "${range}" is outside the ${episodeCount} exported episodes.`, + ); + } + } +} + +/** Validate all metadata that can fail before an export writes any files. */ +export function validateMetadataForExport( + info: LeRobotInfo, + episodes: EpisodeMetadata[], + targetVersion: 'v2.1' | 'v3.0' | undefined, + splits?: Record, +): void { + const isTargetV3 = targetVersion !== undefined ? targetVersion === 'v3.0' : isV3Info(info); + if (!isTargetV3) return; + collectPreservedV3EpisodeColumns(episodes, getV3RewrittenColumns(info)); + validateV3Splits(splits, episodes.length); +} export async function writeMetadata( info: LeRobotInfo, @@ -19,27 +202,38 @@ export async function writeMetadata( signal?: AbortSignal, stats?: DatasetStats, splits?: Record, + dataLayout?: V3DataLayout, ): Promise { if (signal?.aborted) throw new DOMException('Export cancelled', 'AbortError'); - const isTargetV3 = targetVersion?.startsWith('v3') ?? info.codebase_version.startsWith('v3'); + const isTargetV3 = targetVersion !== undefined ? targetVersion === 'v3.0' : isV3Info(info); + const taskPlan = buildExportTaskPlan(episodes, tasks); const { total_videos: legacyTotalVideos, ...officialInfo } = info; + if (isTargetV3) { + // Validate before writing info.json so unsupported extension metadata + // cannot leave a deceptively successful partial export. + validateMetadataForExport(info, episodes, targetVersion, splits); + } const infoToWrite = { ...officialInfo, ...(!isTargetV3 && legacyTotalVideos !== undefined ? { total_videos: legacyTotalVideos } : {}), codebase_version: (targetVersion ?? info.codebase_version) as LeRobotInfo['codebase_version'], - ...(isTargetV3 ? { data_path: V3_DATA_PATH, video_path: V3_VIDEO_PATH } : {}), + ...(isTargetV3 + ? { + chunks_size: info.chunks_size ?? V3_CHUNKS_SIZE_DEFAULT, + data_files_size_in_mb: + (info as { data_files_size_in_mb?: number }).data_files_size_in_mb ?? + V3_DATA_FILE_SIZE_MB_DEFAULT, + video_files_size_in_mb: + (info as { video_files_size_in_mb?: number }).video_files_size_in_mb ?? + V3_VIDEO_FILE_SIZE_MB_DEFAULT, + data_path: V3_DATA_PATH, + video_path: V3_VIDEO_PATH, + } + : {}), total_episodes: episodes.length, total_frames: episodes.reduce((total, episode) => total + episode.length, 0), - total_tasks: new Set( - episodes.flatMap((episode) => - (episode.tasks ?? []).length > 0 - ? (episode.tasks ?? []) - : episode.task_index !== undefined - ? [tasks[episode.task_index] ?? String(episode.task_index)] - : [], - ), - ).size, + total_tasks: Object.keys(taskPlan.tasks).length, ...(isTargetV3 && splits != null && Object.keys(splits).length > 0 ? { splits } : {}), } as LeRobotInfo; @@ -50,12 +244,21 @@ export async function writeMetadata( } if (isTargetV3) { - await writeV3Episodes(episodes, adapter, onProgress, info, videoOffsetsByEpisode, signal); + await writeV3Episodes( + episodes, + taskPlan, + adapter, + onProgress, + info, + videoOffsetsByEpisode, + signal, + dataLayout, + ); if (signal?.aborted) throw new DOMException('Export cancelled', 'AbortError'); - await writeV3Tasks(tasks, adapter, onProgress, signal); + await writeV3Tasks(taskPlan.tasks, adapter, onProgress, signal); } else { - writeV2Episodes(episodes, adapter); - writeV2Tasks(tasks, adapter); + writeV2Episodes(episodes, taskPlan, adapter); + writeV2Tasks(taskPlan.tasks, adapter); } if (stats != null && Object.keys(stats).length > 0) { @@ -63,9 +266,25 @@ export async function writeMetadata( } } -function writeV2Episodes(episodes: EpisodeMetadata[], adapter: ExportAdapter): void { +function writeV2Episodes( + episodes: EpisodeMetadata[], + taskPlan: ExportTaskPlan, + adapter: ExportAdapter, +): void { // Export uses 0-based contiguous episode_index so the exported dataset is self-contained. - const lines = episodes.map((ep, i) => JSON.stringify({ ...ep, episode_index: i })).join('\n'); + const lines = episodes + .map((ep, i) => { + const taskIndices = taskPlan.episodeTaskIndices[i]; + const { task_index: _sourceTaskIndex, ...preservedEpisode } = ep; + void _sourceTaskIndex; + return JSON.stringify({ + ...preservedEpisode, + episode_index: i, + tasks: taskIndices.map((index) => taskPlan.tasks[index]), + ...(taskIndices.length === 1 ? { task_index: taskIndices[0] } : {}), + }); + }) + .join('\n'); adapter.writeFile('meta/episodes.jsonl', TEXT_ENCODER.encode(lines)); } @@ -80,11 +299,13 @@ function writeV2Tasks(tasks: Record, adapter: ExportAdapter): vo async function writeV3Episodes( episodes: EpisodeMetadata[], + taskPlan: ExportTaskPlan, adapter: ExportAdapter, onProgress?: (p: ExportProgress) => void, info?: LeRobotInfo, videoOffsetsByEpisode?: EpisodeVideoOffsets, signal?: AbortSignal, + dataLayout?: V3DataLayout, ): Promise { if (signal?.aborted) throw new DOMException('Export cancelled', 'AbortError'); onProgress?.({ @@ -99,38 +320,58 @@ async function writeV3Episodes( const n = episodes.length; const episodeIndex = episodes.map((_, i) => i); const length = episodes.map((e) => e.length); - const tasksArr = episodes.map((e) => e.tasks ?? []); - const taskIndex = episodes.map((e) => e.task_index ?? 0); + const tasksArr = taskPlan.episodeTaskIndices.map((indices) => + indices.map((index) => taskPlan.tasks[index]), + ); const tableData: Record = { episode_index: episodeIndex, length, tasks: tasksArr, - task_index: taskIndex, }; + Object.assign(tableData, collectPreservedV3EpisodeColumns(episodes, getV3RewrittenColumns(info))); - const chunksSize = info?.chunks_size ?? 1000; - // dataset_from_index / dataset_to_index must point into the *exported* data files (cumulative row offsets). + // dataset_from_index / dataset_to_index are global exported row offsets. let cum = 0; const datasetFromIndex = new Array(n); const datasetToIndex = new Array(n); + const dataLocations = dataLayout + ? episodes.map((episode) => { + const location = dataLayout.episodes.get(episode.episode_index); + if (!location) { + throw new Error(`Missing exported data location for episode ${episode.episode_index}`); + } + return location; + }) + : undefined; for (let i = 0; i < n; i++) { - datasetFromIndex[i] = cum; + const location = dataLocations?.[i]; + if ( + location && + (location.dataset_from_index !== cum || location.dataset_to_index !== cum + length[i]) + ) { + throw new Error( + `Exported data row range for episode ${episodes[i].episode_index} does not match its length`, + ); + } + datasetFromIndex[i] = location?.dataset_from_index ?? cum; cum += length[i]; - datasetToIndex[i] = cum; + datasetToIndex[i] = location?.dataset_to_index ?? cum; } tableData.dataset_from_index = datasetFromIndex; tableData.dataset_to_index = datasetToIndex; - // Data path: one file per chunk (file-000.parquet). v3Adapter uses data/chunk_index, data/file_index when present. - tableData['data/chunk_index'] = episodes.map((_, i) => Math.floor(i / chunksSize)); - tableData['data/file_index'] = episodes.map(() => 0); + // References must match the actual size-rolled data shards. Metadata-only + // exports have no produced layout and retain a single logical shard. + tableData['data/chunk_index'] = episodes.map( + (_, index) => dataLocations?.[index].chunk_index ?? 0, + ); + tableData['data/file_index'] = episodes.map((_, index) => dataLocations?.[index].file_index ?? 0); - // Top-level chunk_index/file_index reflect the DATA layout, not video. - // Previously this used the first video key, which was wrong for multi-camera - // datasets where different cameras may live in different chunks. - tableData.chunk_index = (tableData['data/chunk_index'] as number[]).slice(); - tableData.file_index = (tableData['data/file_index'] as number[]).slice(); + // These columns locate each row's episode-metadata parquet shard. This + // exporter currently emits one shard, so every row points to chunk/file 0. + tableData['meta/episodes/chunk_index'] = episodes.map(() => 0); + tableData['meta/episodes/file_index'] = episodes.map(() => 0); const videoKeys = info ? Object.entries(info.features) @@ -157,11 +398,10 @@ async function writeV3Episodes( const integerColumns = new Set([ 'episode_index', 'length', - 'task_index', 'dataset_from_index', 'dataset_to_index', - 'chunk_index', - 'file_index', + 'meta/episodes/chunk_index', + 'meta/episodes/file_index', 'data/chunk_index', 'data/file_index', ]); @@ -182,7 +422,15 @@ async function writeV3Episodes( ), ]; } - return [name, arrow.vectorFromArray(values)]; + try { + return [name, createPreservedEpisodeVector(values)]; + } catch (error) { + throw new Error( + `Cannot serialize v3 episode metadata column "${name}" to Arrow: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } }), ); const table = new arrow.Table({ @@ -227,10 +475,52 @@ async function writeV3Tasks( // Official v3 also has meta/tasks.parquet (task_index + task columns for Python/pandas compatibility) if (sorted.length > 0) { - const task_index = sorted.map(([idx]) => Number(idx)); + const task_index = arrow.vectorFromArray( + sorted.map(([idx]) => BigInt(Number(idx))), + new arrow.Int64(), + ); const task = sorted.map(([, desc]) => String(desc ?? '')); - const tableData: Record = { task_index, task }; - const table = arrow.tableFromArrays(tableData); + const baseTable = new arrow.Table({ task_index, task: arrow.vectorFromArray(task) }); + // Official LeRobot writes tasks from a pandas DataFrame whose string + // `task` index maps each row to its numeric task_index. A plain Arrow + // column is not enough: pandas otherwise restores a RangeIndex and the + // official reader returns "0", "1", ... as task labels. + const pandasMetadata = JSON.stringify({ + index_columns: ['task'], + column_indexes: [ + { + name: null, + field_name: null, + pandas_type: 'unicode', + numpy_type: 'object', + metadata: { encoding: 'UTF-8' }, + }, + ], + columns: [ + { + name: 'task_index', + field_name: 'task_index', + pandas_type: 'int64', + numpy_type: 'int64', + metadata: null, + }, + { + name: 'task', + field_name: 'task', + pandas_type: 'unicode', + numpy_type: 'object', + metadata: null, + }, + ], + attributes: {}, + creator: { library: 'lerobot-studio', version: '1.0.0' }, + pandas_version: '2.3.3', + }); + const schema = new arrow.Schema( + baseTable.schema.fields, + new Map([...baseTable.schema.metadata, ['pandas', pandasMetadata]]), + ); + const table = new arrow.Table(schema, baseTable.batches); const wasmModule = await import('./parquetWasmLoader').then((m) => m.getParquetWasm()); const ipcBytes = arrow.tableToIPC(table, 'stream'); const ipcCopy = new Uint8Array(ipcBytes.length); diff --git a/src/platform/export/TaskPlan.ts b/src/platform/export/TaskPlan.ts new file mode 100644 index 0000000..d79a12b --- /dev/null +++ b/src/platform/export/TaskPlan.ts @@ -0,0 +1,80 @@ +import type { EpisodeMetadata } from '@/core'; + +export interface ExportTaskPlan { + tasks: Record; + episodeTaskIndices: number[][]; + episodeSourceIndexToTargetIndex: Array>; +} + +/** + * Resolve one frame's final task_index from the canonical export task plan. + * Single-task episodes intentionally ignore stale source frame indices. + */ +export function resolveExportTaskIndex( + taskPlan: ExportTaskPlan, + episodePosition: number, + sourceTaskIndex: number, +): number | undefined { + const episodeTargets = taskPlan.episodeTaskIndices[episodePosition] ?? []; + if (episodeTargets.length === 1) return episodeTargets[0]; + return taskPlan.episodeSourceIndexToTargetIndex[episodePosition]?.get(sourceTaskIndex); +} + +function normalizedLabels(episode: EpisodeMetadata, sourceTasks: Record): string[] { + const labels = Array.isArray(episode.tasks) + ? episode.tasks + .map(String) + .map((task) => task.trim()) + .filter(Boolean) + : []; + if (labels.length > 0) return Array.from(new Set(labels)); + + if (episode.task_index != null) { + const label = sourceTasks[Number(episode.task_index)]; + if (label != null && String(label).trim() !== '') return [String(label).trim()]; + } + const onlySourceTask = Object.values(sourceTasks) + .map((task) => String(task).trim()) + .filter(Boolean); + if (onlySourceTask.length === 1) return onlySourceTask; + return []; +} + +/** Build one canonical task mapping shared by frames, episodes and tasks metadata. */ +export function buildExportTaskPlan( + episodes: EpisodeMetadata[], + sourceTasks: Record, +): ExportTaskPlan { + const labelToTargetIndex = new Map(); + const tasks: Record = {}; + const episodeTaskIndices = episodes.map((episode) => + normalizedLabels(episode, sourceTasks).map((label) => { + let index = labelToTargetIndex.get(label); + if (index === undefined) { + index = labelToTargetIndex.size; + labelToTargetIndex.set(label, index); + tasks[index] = label; + } + return index; + }), + ); + + const episodeSourceIndexToTargetIndex = episodes.map((_, position) => { + const mapping = new Map(); + const allowedTargets = new Set(episodeTaskIndices[position]); + for (const [rawIndex, rawLabel] of Object.entries(sourceTasks)) { + const sourceIndex = Number(rawIndex); + const targetIndex = labelToTargetIndex.get(String(rawLabel).trim()); + if ( + Number.isSafeInteger(sourceIndex) && + targetIndex !== undefined && + allowedTargets.has(targetIndex) + ) { + mapping.set(sourceIndex, targetIndex); + } + } + return mapping; + }); + + return { tasks, episodeTaskIndices, episodeSourceIndexToTargetIndex }; +} diff --git a/src/platform/export/VideoExporter.ts b/src/platform/export/VideoExporter.ts index 50aeb21..893f1f6 100644 --- a/src/platform/export/VideoExporter.ts +++ b/src/platform/export/VideoExporter.ts @@ -5,6 +5,7 @@ import type { ExportAdapter } from '@/core'; import type { ExportProgress, EpisodeVideoOffsets, TargetVersion } from '@/core'; const CHUNK_SIZE_DEFAULT = 1000; +const VIDEO_FILE_SIZE_MB_DEFAULT = 200; export type VideoExportOptions = { signal?: AbortSignal }; @@ -190,21 +191,19 @@ async function exportVideosTranscodeToV3( if (videoKeys.length === 0) return new Map(); const chunksSize = info.chunks_size ?? CHUNK_SIZE_DEFAULT; - - // Group by NEW 0-based position so the output layout is self-contained. - const chunkToIndices = new Map(); - for (let i = 0; i < episodes.length; i++) { - const c = Math.floor(i / chunksSize); - if (!chunkToIndices.has(c)) chunkToIndices.set(c, []); - chunkToIndices.get(c)!.push(i); + if (!Number.isSafeInteger(chunksSize) || chunksSize <= 0) { + throw new Error('chunks_size must be a positive integer'); + } + const videoSizeLimit = + (info as { video_files_size_in_mb?: number }).video_files_size_in_mb ?? + VIDEO_FILE_SIZE_MB_DEFAULT; + if (!Number.isFinite(videoSizeLimit) || videoSizeLimit <= 0) { + throw new Error('video_files_size_in_mb must be a positive number'); } const offsets: EpisodeVideoOffsets = new Map(); let done = 0; - const total = [...chunkToIndices.values()].reduce( - (sum, eps) => sum + eps.length * videoKeys.length, - 0, - ); + const total = episodes.length * videoKeys.length; onProgress?.({ phase: 'videos', current: 0, @@ -213,76 +212,85 @@ async function exportVideosTranscodeToV3( cancelable: false, }); - for (const [chunkIdx, indices] of chunkToIndices.entries()) { - for (const key of videoKeys) { - await ensureParentDir(adapter, `videos/${key}/chunk-${String(chunkIdx).padStart(3, '0')}`); - for (let position = 0; position < indices.length; position++) { - assertNotAborted(signal); - const newIdx = indices[position]; - const ep = episodes[newIdx]; - const pathResult = dataLoader.getEpisodeVideoPath(ep.episode_index, key); - const outPath = `videos/${key}/chunk-${String(chunkIdx).padStart(3, '0')}/file-${String(position).padStart(3, '0')}.mp4`; + for (const key of videoKeys) { + // Official LeRobot concatenates episode MP4s while the current file stays + // below video_files_size_in_mb. Mediabunny cannot remux-concatenate + // independently encoded MP4s without a lossy transcode. Preserve every + // episode losslessly as its own file instead, and apply chunks_size to the + // number of files (not the number of episodes as a format concept). + let chunkIdx = 0; + let fileIdx = 0; + for (let newIdx = 0; newIdx < episodes.length; newIdx++) { + assertNotAborted(signal); + const ep = episodes[newIdx]; + const pathResult = dataLoader.getEpisodeVideoPath(ep.episode_index, key); + const outPath = `videos/${key}/chunk-${String(chunkIdx).padStart(3, '0')}/file-${String(fileIdx).padStart(3, '0')}.mp4`; - if (!pathResult) { - throw new Error(`Missing source video for episode ${ep.episode_index}, feature "${key}"`); - } + if (!pathResult) { + throw new Error(`Missing source video for episode ${ep.episode_index}, feature "${key}"`); + } - try { - const bytes = await dataLoader.readFileBytes(pathResult.path); - const hasTrim = hasMeaningfulTrim(pathResult); - const trim = hasTrim - ? { fromSec: pathResult.fromSec, toSec: pathResult.toSec } - : undefined; + try { + const bytes = await dataLoader.readFileBytes(pathResult.path); + const hasTrim = hasMeaningfulTrim(pathResult); + const trim = hasTrim ? { fromSec: pathResult.fromSec, toSec: pathResult.toSec } : undefined; - // Fast path: source is already an MP4 AND we don't need to trim → - // byte copy. Massively faster and avoids any transcoding failure - // mode for already-compatible sources (v2 MP4 → v3 MP4). - if (!hasTrim && looksLikeMp4(bytes)) { - await adapter.writeFile(outPath, bytes); - const duration = - Number(pathResult.toSec ?? 0) > 0 - ? Number(pathResult.toSec) - : await tryComputeMp4Duration(bytes); + // Fast path: source is already an MP4 AND we don't need to trim → + // byte copy. Massively faster and avoids any transcoding failure + // mode for already-compatible sources (v2 MP4 → v3 MP4). + if (!hasTrim && looksLikeMp4(bytes)) { + await ensureParentDir(adapter, outPath); + await adapter.writeFile(outPath, bytes); + const duration = + Number(pathResult.toSec ?? 0) > 0 + ? Number(pathResult.toSec) + : await tryComputeMp4Duration(bytes); + if (!offsets.has(ep.episode_index)) offsets.set(ep.episode_index, {}); + offsets.get(ep.episode_index)![key] = { + chunk_index: chunkIdx, + file_index: fileIdx, + from_timestamp: 0, + to_timestamp: duration, + }; + } else { + const result = await convertSegmentWithMediabunny(bytes, trim, signal); + if (!result) { + throw new Error(`Video conversion produced no MP4 output: ${outPath}`); + } else { + await ensureParentDir(adapter, outPath); + await adapter.writeFile(outPath, new Uint8Array(result.buffer)); if (!offsets.has(ep.episode_index)) offsets.set(ep.episode_index, {}); offsets.get(ep.episode_index)![key] = { chunk_index: chunkIdx, - file_index: position, + file_index: fileIdx, from_timestamp: 0, - to_timestamp: duration, + to_timestamp: result.duration, }; - } else { - const result = await convertSegmentWithMediabunny(bytes, trim, signal); - if (!result) { - throw new Error(`Video conversion produced no MP4 output: ${outPath}`); - } else { - await adapter.writeFile(outPath, new Uint8Array(result.buffer)); - if (!offsets.has(ep.episode_index)) offsets.set(ep.episode_index, {}); - offsets.get(ep.episode_index)![key] = { - chunk_index: chunkIdx, - file_index: position, - from_timestamp: 0, - to_timestamp: result.duration, - }; - } } - } catch (e) { - if (e instanceof DOMException && e.name === 'AbortError') throw e; - throw new Error( - `Failed to export video "${outPath}": ${e instanceof Error ? e.message : String(e)}`, - { - cause: e, - }, - ); } + } catch (e) { + if (e instanceof DOMException && e.name === 'AbortError') throw e; + throw new Error( + `Failed to export video "${outPath}": ${e instanceof Error ? e.message : String(e)}`, + { + cause: e, + }, + ); + } - done++; - onProgress?.({ - phase: 'videos', - current: done, - total, - message: outPath, - cancelable: false, - }); + done++; + onProgress?.({ + phase: 'videos', + current: done, + total, + message: outPath, + cancelable: false, + }); + if (fileIdx + 1 >= chunksSize) { + chunkIdx++; + fileIdx = 0; + } else { + fileIdx++; } } } diff --git a/src/platform/services/LeRobotDataLoader.ts b/src/platform/services/LeRobotDataLoader.ts index 79d71eb..70b1333 100644 --- a/src/platform/services/LeRobotDataLoader.ts +++ b/src/platform/services/LeRobotDataLoader.ts @@ -9,8 +9,9 @@ import type { EpisodeVideoPathResult, LeRobotVersionAdapter, ValidationReport, + LeRobotVersionCapability, } from '@/core'; -import { getAdapterForVersion, getValidatorForVersion } from '@/core'; +import { classifyLeRobotVersion, getAdapterForVersion, getValidatorForVersion } from '@/core'; import { tableFromIPC, Table } from 'apache-arrow'; import { LRUCache } from '../utils/MediaCache'; import type { DataSource } from '../datasource/dataSources'; @@ -42,6 +43,11 @@ export class LeRobotDataLoader { private fileUrlCache: LRUCache; private disposed = false; private validationReport: ValidationReport | null = null; + private versionCapability: LeRobotVersionCapability = { + status: 'unsupported', + normalizedVersion: null, + adapterVersion: null, + }; // 文件解析缓存 - 避免重复读取和解析同一个文件 private parsedFileCache: ParsedFileCache | null = null; @@ -73,9 +79,14 @@ export class LeRobotDataLoader { } try { await this.worker.clearCache(); - terminateWorker(this.worker); } catch { // ignore + } finally { + try { + terminateWorker(this.worker); + } catch { + // ignore + } } try { await this._dataSource.clear(); @@ -95,8 +106,11 @@ export class LeRobotDataLoader { ); } - if (!this.info.codebase_version) { - throw new Error('Invalid info.json: codebase_version is missing'); + this.versionCapability = classifyLeRobotVersion(this.info.codebase_version); + if (this.versionCapability.status === 'unsupported') { + throw new Error( + `Unsupported LeRobot codebase_version: ${String(this.info.codebase_version ?? 'missing')}`, + ); } this.adapter = getAdapterForVersion(this.info.codebase_version); @@ -120,7 +134,7 @@ export class LeRobotDataLoader { // meta/tasks.parquet. Official Python writers store per-episode labels in the // ``tasks`` list column only — there is often no episode-level ``task_index``. // Defaulting missing task_index to 0 incorrectly showed every episode as task 0. - if (this.adapter.version.startsWith('v3')) { + if (this.versionCapability.adapterVersion === 'v3.0') { this.episodes.forEach((ep) => { if (ep.task_index == null) return; const idx = Number(ep.task_index); @@ -339,6 +353,10 @@ export class LeRobotDataLoader { return this.validationReport; } + getVersionCapability(): LeRobotVersionCapability { + return { ...this.versionCapability }; + } + /** * Returns the Arrow table for a single episode (for export). * When episode is a slice of a shared file (e.g. v3), returns sliced table; otherwise full table. diff --git a/src/platform/workers/wasmUrl.inline.ts b/src/platform/workers/wasmUrl.inline.ts deleted file mode 100644 index 47f94be..0000000 --- a/src/platform/workers/wasmUrl.inline.ts +++ /dev/null @@ -1 +0,0 @@ -export { default as wasmUrl } from 'parquet-wasm/esm/parquet_wasm_bg.wasm?url&inline'; diff --git a/src/platform/workers/workerManager.ts b/src/platform/workers/workerManager.ts index 68f3266..375a9ba 100644 --- a/src/platform/workers/workerManager.ts +++ b/src/platform/workers/workerManager.ts @@ -1,9 +1,8 @@ /** - * Worker 管理器 - 使用 Vite 原生 ?worker&inline 特性 + * Worker 管理器 - 使用 Vite 原生外置 module Worker * - * 由于内联 Worker 运行在 Blob URL 环境,parquet-wasm 无法通过 import.meta.url - * 正确解析 WASM 文件路径("Invalid URL" 错误)。解决方案是在主线程预加载 - * WASM 为 ArrayBuffer,然后通过 postMessage Transfer 传递给 Worker。 + * Worker 使用相对模块 URL,WASM 保留为单份按需 data URL chunk,因此不依赖部署根路径。 + * WASM 由主线程预加载为 ArrayBuffer,再通过 postMessage Transfer 传递给 Worker。 * * Worker 侧使用 Promise 等待(而非轮询),彻底消除竞态条件: * 无论主线程多快/多慢完成 fetch,Worker 都会等到 buffer 到达后再初始化。 @@ -12,13 +11,8 @@ import * as Comlink from 'comlink'; import type { ParquetWorkerAPI, ParquetImageWorkerAPI } from '@/core'; -// SPA build: ?url -> separate .wasm file fetched on demand -// lib build: vite.lib.config.ts aliases this to wasmUrl.inline.ts (?url&inline -> base64 data URL) -import { wasmUrl } from './wasmUrl'; - -// Vite 会自动将 worker 内联为 base64 -import ParquetWorkerConstructor from './parquet.worker?worker&inline'; -import ParquetImageWorkerConstructor from './parquetImage.worker?worker&inline'; +import parquetWorkerUrl from './parquet.worker?worker&url'; +import parquetImageWorkerUrl from './parquetImage.worker?worker&url'; // WASM 预加载缓存(Promise 形式,多个 worker 共享同一份 fetch) let wasmBufferPromise: Promise | null = null; @@ -29,11 +23,12 @@ const workerRegistry = new WeakMap, Worker>(); */ async function loadWasmBuffer(): Promise { if (!wasmBufferPromise) { - wasmBufferPromise = fetch(wasmUrl) + // Library mode places this data URL in its own lazy chunk. Keeping one + // package-local copy is relocatable across Vite, Next, and arbitrary bases. + wasmBufferPromise = import('./wasmUrl') + .then(({ wasmUrl }) => fetch(wasmUrl)) .then((response) => { - if (!response.ok) { - throw new Error(`Failed to fetch WASM: ${response.status}`); - } + if (!response.ok) throw new Error(`Failed to fetch WASM: ${response.status}`); return response.arrayBuffer(); }) .catch((err) => { @@ -63,7 +58,7 @@ function sendWasmToWorker(worker: Worker): void { * 创建 Parquet Worker 实例并传递 WASM ArrayBuffer */ export function createParquetWorker(): Comlink.Remote { - const worker = new ParquetWorkerConstructor(); + const worker = new Worker(parquetWorkerUrl, { type: 'module' }); sendWasmToWorker(worker); const remote = Comlink.wrap(worker); workerRegistry.set(remote as Comlink.Remote, worker); @@ -79,7 +74,7 @@ export function createParquetImageWorker(): { remote: Comlink.Remote; raw: Worker; } { - const worker = new ParquetImageWorkerConstructor(); + const worker = new Worker(parquetImageWorkerUrl, { type: 'module' }); sendWasmToWorker(worker); const remote = Comlink.wrap(worker); workerRegistry.set(remote as Comlink.Remote, worker); diff --git a/src/react/components/Commons/ErrorState.tsx b/src/react/components/Commons/ErrorState.tsx index 9d4cad1..d97a703 100644 --- a/src/react/components/Commons/ErrorState.tsx +++ b/src/react/components/Commons/ErrorState.tsx @@ -26,7 +26,11 @@ export const ErrorState: React.FC = ({ title, message, onRetry, }; return ( -
+
@@ -47,6 +51,7 @@ export const ErrorState: React.FC = ({ title, message, onRetry, size="icon" className="h-6 w-6" onClick={handleCopy} + aria-label={copied ? t('common.copied', 'Copied') : t('common.copy')} title={t('common.copy')} > {copied ? : } diff --git a/src/react/components/ErrorBoundary.tsx b/src/react/components/ErrorBoundary.tsx index 2016105..41f34c8 100644 --- a/src/react/components/ErrorBoundary.tsx +++ b/src/react/components/ErrorBoundary.tsx @@ -2,10 +2,13 @@ import { Component } from 'react'; import type { ErrorInfo, ReactNode } from 'react'; import { AlertCircle, RotateCcw } from 'lucide-react'; import { Button } from '@/ui'; +import { useTranslation } from 'react-i18next'; +import { I18nProvider } from '../i18n/core'; interface Props { children?: ReactNode; fallback?: ReactNode; + renderFallback?: (props: ErrorFallbackProps) => ReactNode; } interface State { @@ -13,6 +16,50 @@ interface State { error: Error | null; } +export interface ErrorFallbackProps { + error: Error | null; + onReset: () => void; +} + +export function formatErrorForDisplay(error: Error | null, includeStack: boolean): string { + if (!error) return ''; + if (!includeStack || !error.stack) return error.message; + return `${error.message}\n\n${error.stack}`; +} + +/** Function component kept separate so the class boundary can use i18n and tests can render it directly. */ +export function ErrorFallback({ error, onReset }: ErrorFallbackProps) { + const { t } = useTranslation(); + const errorDetails = formatErrorForDisplay(error, import.meta.env.DEV); + + return ( +
+
+ +
+

{t('errorBoundary.title')}

+

+ {t('errorBoundary.description')} +

+
+

+ {t('errorBoundary.errorLabel')}: +

+
+          {errorDetails || t('errorBoundary.unknownError')}
+        
+
+ +
+ ); +} + export class ErrorBoundary extends Component { public state: State = { hasError: false, @@ -38,29 +85,15 @@ export class ErrorBoundary extends Component { return this.props.fallback; } + const fallbackProps = { error: this.state.error, onReset: this.handleReset }; + if (this.props.renderFallback) { + return this.props.renderFallback(fallbackProps); + } + return ( -
-
- -
-

Something went wrong

-

- An unexpected error occurred in the application. We've been notified and are working to - fix it. -

-
-

Error:

-
-              {this.state.error?.message || 'Unknown error'}
-              {'\n\nStack trace:\n'}
-              {this.state.error?.stack}
-            
-
- -
+ + + ); } diff --git a/src/react/components/LeRobotViewer.tsx b/src/react/components/LeRobotViewer.tsx index 981acf9..6180c99 100644 --- a/src/react/components/LeRobotViewer.tsx +++ b/src/react/components/LeRobotViewer.tsx @@ -1,35 +1,68 @@ -import React, { useEffect, useState, useRef, useCallback } from 'react'; +import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { LeRobotStudioProvider } from './LeRobotStudioProvider'; import { EpisodeSidebar } from './Sidebar/EpisodeSidebar'; import { Button } from '@/ui'; import { ArrowLeft, Download } from 'lucide-react'; -import type { DataSource } from '@/platform'; -import { createArchiveDataSourceFromUrl, getArchiveKindFromUrl } from '@/platform'; -import { preflightRemoteArchive, translateRemotePreflightFailure } from '@/platform'; +import type { DataSource } from '../../core/datasource/types'; +import { + createArchiveDataSourceFromUrl, + getArchiveKindFromUrl, +} from '../../platform/datasource/ArchiveDataSourceFactory'; +import { + preflightRemoteArchive, + translateRemotePreflightFailure, +} from '../../platform/datasource/remotePreflight'; import { useLeRobotData } from '../contexts/LeRobotContext'; import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'; import { ViewerLayout } from '../features/viewer/ViewerLayout'; import { ErrorState } from './Commons/ErrorState'; +/** Stable error categories reported to an embedding host. */ +export type LeRobotViewerErrorCode = + | 'INVALID_DATA_SOURCE' + | 'REMOTE_SOURCE_UNAVAILABLE' + | 'UNSUPPORTED_ARCHIVE' + | 'DATASET_LOAD_FAILED'; + +/** A fatal viewer error that requires host action or an explicit retry. */ +export interface LeRobotViewerError { + /** Machine-readable category. */ + code: LeRobotViewerErrorCode; + /** Localized message suitable for display. */ + message: string; + /** Whether retrying the same operation may succeed. */ + recoverable: boolean; + /** Original failure when one is available. */ + cause?: unknown; +} + +/** Properties for the stable, client-only LeRobot dataset viewer. */ export interface LeRobotViewerProps { + /** Remote archive URL or a custom browser data source. */ dataSource: string | DataSource; + /** Viewer color scheme. @defaultValue 'system' */ theme?: 'light' | 'dark' | 'system'; + /** BCP 47 language tag. Built-in messages currently cover en, zh, and ja. */ language?: string; + /** Show the episode sidebar. @defaultValue true */ showSidebar?: boolean; + /** Show playback controls. @defaultValue true */ showPlaybackBar?: boolean; + /** Additional class name applied to the scoped viewer root. */ className?: string; + /** Optional host-owned navigation action. */ onBack?: () => void; - /** 当提供时,在侧栏返回按钮下方显示导出按钮,点击后触发。 */ + /** Optional host-owned export action; the npm viewer does not export data itself. */ onExport?: () => void; /** - * 严重错误回调:当数据源解析失败或加载失败(如 CORS / 网络错误)时触发。 - * 典型用法:宿主应用收到回调后清空 url query 并提示用户,而不是显示 lerobot 内置错误页。 + * Receives fatal source or dataset loading failures. When provided, the host + * owns fatal-error rendering and the built-in fatal state is not rendered. */ - onFatalError?: (errorMessage: string) => void; + onFatalError?: (error: LeRobotViewerError) => void; /** - * 是否启用键盘快捷键(空格播放/暂停,方向键跳转帧等) - * @default true + * Enable viewer keyboard shortcuts such as Space and arrow-key seeking. + * @defaultValue true */ enableKeyboardShortcuts?: boolean; } @@ -53,9 +86,21 @@ export function LeRobotViewerContent({ const lastSourceRef = useRef(null); const [resolvedSource, setResolvedSource] = useState(null); - const [resolveError, setResolveError] = useState(null); + const [resolveError, setResolveError] = useState(null); const [isResolving, setIsResolving] = useState(false); - const lastFatalErrorRef = useRef(error); + const lastFatalErrorRef = useRef(null); + const fatalError = useMemo( + () => + resolveError ?? + (error + ? { + code: 'DATASET_LOAD_FAILED', + message: error, + recoverable: true, + } + : null), + [error, resolveError], + ); const normalizeUrl = (raw: string) => { try { @@ -81,7 +126,11 @@ export function LeRobotViewerContent({ const raw = dataSource.trim(); if (!raw) { setResolvedSource(null); - setResolveError(t('common.error') + ': empty dataSource url'); + setResolveError({ + code: 'INVALID_DATA_SOURCE', + message: `${t('common.error')}: empty dataSource url`, + recoverable: false, + }); return; } @@ -91,11 +140,23 @@ export function LeRobotViewerContent({ try { const pre = await preflightRemoteArchive(url); if (!pre.ok) { - throw new Error(translateRemotePreflightFailure(t, pre.failure)); + setResolvedSource(null); + setResolveError({ + code: 'REMOTE_SOURCE_UNAVAILABLE', + message: translateRemotePreflightFailure(t, pre.failure), + recoverable: true, + }); + return; } const kind = getArchiveKindFromUrl(url) || pre.kind; if (!kind) { - throw new Error(t('validation.unsupportedFormat')); + setResolvedSource(null); + setResolveError({ + code: 'UNSUPPORTED_ARCHIVE', + message: t('validation.unsupportedFormat'), + recoverable: false, + }); + return; } if (cancelled) return; setResolvedSource( @@ -107,7 +168,12 @@ export function LeRobotViewerContent({ if (cancelled) return; const msg = e instanceof Error ? e.message : String(e); setResolvedSource(null); - setResolveError(msg); + setResolveError({ + code: 'REMOTE_SOURCE_UNAVAILABLE', + message: msg, + recoverable: true, + cause: e, + }); } finally { if (!cancelled) setIsResolving(false); } @@ -127,19 +193,18 @@ export function LeRobotViewerContent({ }, [resolvedSource, initialize]); useEffect(() => { - if (!error && !resolveError) { + if (!fatalError) { lastFatalErrorRef.current = null; } - }, [error, resolveError]); + }, [fatalError]); useEffect(() => { - if (!onFatalError) return; - const msg = resolveError || error; - if (!msg) return; - if (lastFatalErrorRef.current === msg) return; - lastFatalErrorRef.current = msg; - onFatalError(msg); - }, [resolveError, error, onFatalError]); + if (!onFatalError || !fatalError) return; + const key = `${fatalError.code}:${fatalError.message}`; + if (lastFatalErrorRef.current === key) return; + lastFatalErrorRef.current = key; + onFatalError(fatalError); + }, [fatalError, onFatalError]); const handleRetry = useCallback(() => { clearError(); @@ -157,7 +222,7 @@ export function LeRobotViewerContent({ } }, [clearError, resolvedSource, initialize, dataSource]); - if (onFatalError && (resolveError || error)) { + if (onFatalError && fatalError) { // The embedding host owns fatal-error rendering via onFatalError. Keeping a // spinner mounted here made permanent failures indistinguishable from load. return null; @@ -199,24 +264,30 @@ export function LeRobotViewerContent({
) : null; - const emptyState = - resolveError || error ? ( - - ) : ( -
-
-
- - {isResolving || isLoading ? t('common.initializing') : t('common.loading')} - -
+ const emptyState = fatalError ? ( + + ) : ( +
+
+
+ + {isResolving || isLoading ? t('common.initializing') : t('common.loading')} +
- ); +
+ ); return ( = ({
{/* Center: Title & Logo */} -
+
{isLoading ? ( - + ) : !datasetName ? ( LeRobot Studio Logo ) : null} @@ -345,7 +352,14 @@ export const Navbar: React.FC = ({ {/* Bottom Progress Bar */} {(activeTask || percent !== undefined) && ( -
+
{activeTask && activeTask.phase === 'error' ? (
) : percent !== undefined ? ( diff --git a/src/react/components/PanelTitle.tsx b/src/react/components/PanelTitle.tsx index de9b91f..20fbcb1 100644 --- a/src/react/components/PanelTitle.tsx +++ b/src/react/components/PanelTitle.tsx @@ -15,7 +15,7 @@ import { import { Plus, X, Video, Image, TrendingUp, FileText, ArrowDown, ArrowRight } from 'lucide-react'; import type { IDockviewPanelHeaderProps } from 'dockview-react'; import { useTranslation } from 'react-i18next'; -import { useLeRobot } from '../contexts/LeRobotContext'; +import { useLeRobotData } from '../contexts/LeRobotContext'; import { safeAddPanel } from '../utils/dockviewPanelId'; type PanelType = 'video' | 'image' | 'chart' | 'raw'; @@ -23,7 +23,7 @@ type PanelType = 'video' | 'image' | 'chart' | 'raw'; export const PanelTitle: React.FC = (props) => { const { api, containerApi } = props; const { t } = useTranslation(); - const { info } = useLeRobot(); + const { info } = useLeRobotData(); const panelId = api.id; const panel = containerApi.getPanel(panelId); diff --git a/src/react/components/Playback/PlaybackBar.tsx b/src/react/components/Playback/PlaybackBar.tsx index e6b55e3..08e2299 100644 --- a/src/react/components/Playback/PlaybackBar.tsx +++ b/src/react/components/Playback/PlaybackBar.tsx @@ -129,7 +129,12 @@ export const PlaybackBar: React.FC = () => { // Render skeleton if loading if (isLoading || activeTask) { return ( -
+
diff --git a/src/react/components/Sidebar/AnalysisSidebarContent.tsx b/src/react/components/Sidebar/AnalysisSidebarContent.tsx index b73bdb0..49d8eb8 100644 --- a/src/react/components/Sidebar/AnalysisSidebarContent.tsx +++ b/src/react/components/Sidebar/AnalysisSidebarContent.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { BarChart3 } from 'lucide-react'; -import { useLeRobot } from '../../contexts/LeRobotContext'; +import { useLeRobotData, useLeRobotSelection } from '../../contexts/LeRobotContext'; import { ScrollArea } from '@/ui'; import { Separator } from '@/ui'; import { AnalysisOverview } from './analysis/AnalysisOverview'; @@ -10,7 +10,8 @@ import { AnalysisTaskDistribution } from './analysis/AnalysisTaskDistribution'; export const AnalysisSidebarContent: React.FC = () => { const { t } = useTranslation(); - const { info, effectiveEpisodes, tasks } = useLeRobot(); + const { info, tasks } = useLeRobotData(); + const { effectiveEpisodes } = useLeRobotSelection(); const analysis = useMemo(() => { if (!info || effectiveEpisodes.length === 0) return null; @@ -73,7 +74,7 @@ export const AnalysisSidebarContent: React.FC = () => { totalFrames={analysis.totalFrames} fps={analysis.fps} codebaseVersion={analysis.codebaseVersion} - robotType={analysis.robotType} + robotType={analysis.robotType ?? undefined} /> { const { t } = useTranslation(); + const { episodes, tasks, isLoading, error, info, versionCapability, isReadOnly } = + useLeRobotData(); const { - episodes, - tasks, selectEpisode, selectedEpisodeIndex, selectedEpisodeIndices, toggleEpisodeSelection, selectAllInList, clearEpisodeSelection, - isLoading, - error, - info, deletedEpisodes, getEffectiveEpisode, editEpisodeTask, deleteEpisode, restoreEpisode, - } = useLeRobot(); + } = useLeRobotSelection(); const [searchTerm, setSearchTerm] = useState(''); const [editMode, setEditMode] = useState(false); const [multiSelectMode, setMultiSelectMode] = useState(false); @@ -43,6 +40,16 @@ export const EpisodeSidebar: React.FC = () => { const [showAdvanced, setShowAdvanced] = useState(false); const [bulkEditOpen, setBulkEditOpen] = useState(false); const [bulkEditText, setBulkEditText] = useState(''); + const mutationDisabled = isReadOnly || Boolean(info && versionCapability?.status !== 'supported'); + + useEffect(() => { + if (!mutationDisabled) return; + setEditMode(false); + setMultiSelectMode(false); + setEditDialogOpen(false); + setBulkEditOpen(false); + clearEpisodeSelection(); + }, [mutationDisabled, clearEpisodeSelection]); const filteredEpisodes = useMemo(() => { if (!episodes) return []; @@ -75,17 +82,20 @@ export const EpisodeSidebar: React.FC = () => { const openEditDialog = (e: React.MouseEvent, episode: EpisodeMetadata) => { e.stopPropagation(); + if (mutationDisabled) return; setEpisodeToEdit(getEffectiveEpisode(episode)); setEditDialogOpen(true); }; const handleDelete = (e: React.MouseEvent, episodeIndex: number) => { e.stopPropagation(); + if (mutationDisabled) return; deleteEpisode(episodeIndex); }; const handleRestore = (e: React.MouseEvent, episodeIndex: number) => { e.stopPropagation(); + if (mutationDisabled) return; restoreEpisode(episodeIndex); }; @@ -112,6 +122,7 @@ export const EpisodeSidebar: React.FC = () => { ); const handleBulkEditSave = () => { + if (mutationDisabled) return; const text = bulkEditText.trim(); selectedInFiltered.forEach((ep) => editEpisodeTask(ep.episode_index, text || '')); setBulkEditOpen(false); @@ -157,12 +168,20 @@ export const EpisodeSidebar: React.FC = () => { totalCount={episodes?.length} editMode={editMode} multiSelectMode={multiSelectMode} + isReadOnly={mutationDisabled} onToggleEditMode={() => setEditMode((v) => !v)} onToggleMultiSelectMode={() => { setMultiSelectMode((v) => !v); if (multiSelectMode) clearEpisodeSelection(); }} /> + {mutationDisabled && versionCapability && ( +

+ {t('sidebar.readOnlyVersion', { + version: versionCapability.normalizedVersion ?? info?.codebase_version ?? 'unknown', + })} +

+ )} { onDurationMaxChange={setDurationMax} /> )} - {multiSelectMode && ( + {!mutationDisabled && multiSelectMode && ( { episodes={episodes} filteredEpisodes={listRows} isLoading={isLoading} - multiSelectMode={multiSelectMode} - editMode={editMode} + multiSelectMode={!mutationDisabled && multiSelectMode} + editMode={!mutationDisabled && editMode} onRowClick={handleRowClick} onToggleSelection={(episode, event) => { event.preventDefault(); @@ -213,39 +232,43 @@ export const EpisodeSidebar: React.FC = () => { - - - - - {t('sidebar.bulkEdit', 'Edit task')} - -
-

- {t('sidebar.selectedCount', { count: selectedInFiltered.length })} -{' '} - {t('editTask.taskDescription')} -

-