diff --git a/.gitignore b/.gitignore index 3312b2487..fa6e531d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Miscellaneous *.class +**/node_modules/ *.log *.pyc *.swp @@ -45,3 +46,10 @@ mason-lock.json # FVM Version Cache .fvm/ + +# VS Code extension (stac-vscode) +tools/stac-vscode/out/ +tools/stac-vscode/build/ +*.vsix +.eslintcache +.vscode-test/ diff --git a/examples/counter_example/pubspec.lock b/examples/counter_example/pubspec.lock index 160130578..5062314b0 100644 --- a/examples/counter_example/pubspec.lock +++ b/examples/counter_example/pubspec.lock @@ -452,10 +452,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -861,10 +861,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.10" timing: dependency: transitive description: diff --git a/examples/movie_app/pubspec.lock b/examples/movie_app/pubspec.lock index 1c1ef626f..7ed7c023e 100644 --- a/examples/movie_app/pubspec.lock +++ b/examples/movie_app/pubspec.lock @@ -388,10 +388,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -789,10 +789,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.10" typed_data: dependency: transitive description: diff --git a/examples/stac_gallery/pubspec.lock b/examples/stac_gallery/pubspec.lock index 20311003d..ff6d69155 100644 --- a/examples/stac_gallery/pubspec.lock +++ b/examples/stac_gallery/pubspec.lock @@ -428,10 +428,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -860,10 +860,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.10" typed_data: dependency: transitive description: diff --git a/tools/stac-vscode/.vscode-test.mjs b/tools/stac-vscode/.vscode-test.mjs new file mode 100644 index 000000000..b62ba25f0 --- /dev/null +++ b/tools/stac-vscode/.vscode-test.mjs @@ -0,0 +1,5 @@ +import { defineConfig } from '@vscode/test-cli'; + +export default defineConfig({ + files: 'out/test/**/*.test.js', +}); diff --git a/tools/stac-vscode/.vscode/extensions.json b/tools/stac-vscode/.vscode/extensions.json new file mode 100644 index 000000000..186459d58 --- /dev/null +++ b/tools/stac-vscode/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "dbaeumer.vscode-eslint", + "ms-vscode.extension-test-runner" + ] +} diff --git a/tools/stac-vscode/.vscode/launch.json b/tools/stac-vscode/.vscode/launch.json new file mode 100644 index 000000000..4e947522a --- /dev/null +++ b/tools/stac-vscode/.vscode/launch.json @@ -0,0 +1,21 @@ +// A launch configuration that compiles the extension and then opens it inside a new window +// Use IntelliSense to learn about possible attributes. +// Hover to view descriptions of existing attributes. +// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Extension", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}" + ], + "outFiles": [ + "${workspaceFolder}/out/**/*.js" + ], + "preLaunchTask": "${defaultBuildTask}" + } + ] +} \ No newline at end of file diff --git a/tools/stac-vscode/.vscode/settings.json b/tools/stac-vscode/.vscode/settings.json new file mode 100644 index 000000000..afdab66cc --- /dev/null +++ b/tools/stac-vscode/.vscode/settings.json @@ -0,0 +1,11 @@ +// Place your settings in this file to overwrite default and user settings. +{ + "files.exclude": { + "out": false // set this to true to hide the "out" folder with the compiled JS files + }, + "search.exclude": { + "out": true // set this to false to include "out" folder in search results + }, + // Turn off tsc task auto detection since we have the necessary tasks as npm scripts + "typescript.tsc.autoDetect": "off" +} diff --git a/tools/stac-vscode/.vscode/tasks.json b/tools/stac-vscode/.vscode/tasks.json new file mode 100644 index 000000000..3b17e53b6 --- /dev/null +++ b/tools/stac-vscode/.vscode/tasks.json @@ -0,0 +1,20 @@ +// See https://go.microsoft.com/fwlink/?LinkId=733558 +// for the documentation about the tasks.json format +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "watch", + "problemMatcher": "$tsc-watch", + "isBackground": true, + "presentation": { + "reveal": "never" + }, + "group": { + "kind": "build", + "isDefault": true + } + } + ] +} diff --git a/tools/stac-vscode/.vscodeignore b/tools/stac-vscode/.vscodeignore new file mode 100644 index 000000000..728d529a4 --- /dev/null +++ b/tools/stac-vscode/.vscodeignore @@ -0,0 +1,15 @@ +.vscode/** +.vscode-test/** +src/** +.gitignore +.yarnrc +vsc-extension-quickstart.md +**/tsconfig.json +**/eslint.config.mjs +**/*.map +**/*.ts +**/.vscode-test.* +preview_host/.dart_tool/** +preview_host/build/** +preview_host/.flutter-plugins +preview_host/.flutter-plugins-dependencies diff --git a/tools/stac-vscode/CHANGELOG.md b/tools/stac-vscode/CHANGELOG.md new file mode 100644 index 000000000..79ecc98cb --- /dev/null +++ b/tools/stac-vscode/CHANGELOG.md @@ -0,0 +1,25 @@ +# Change Log + +All notable changes to the "stac-vscode" extension will be documented in this file. + +## [0.1.0] + +### Live Preview +- Side-by-side preview panel for any `@StacScreen` — updates on save. +- Android / iOS / Web device toggles with `TargetPlatform` simulation (scroll physics, page transitions, AppBar behavior). +- Theme discovery from `@StacThemeRef` annotations with live theme selection dropdown. +- Multi-screen support with automatic cursor-based screen switching. +- Runner fast-path JSON generation (`screen().toJson()`) with build fallback. +- Automatic port recovery when the preview host port is in use. +- Mobile viewport frame with rounded border styling. + +### Wrap Quick Fixes +- Cmd+. quick-fix wrapping for Stac widgets in Dart files. +- Presets: `StacContainer`, `StacPadding`, `StacCenter`, `StacAlign`, `StacSizedBox`, `StacExpanded`. +- "Wrap with Stac widget…" for any Stac widget class. +- Auto-generated widget catalog from `packages/stac_core`. + +### Snippets +- `stac screen` — new screen template. +- `stac theme` — new theme template. +- Context-aware: only shown in Stac DSL files. diff --git a/tools/stac-vscode/LICENSE b/tools/stac-vscode/LICENSE new file mode 100644 index 000000000..ee6ff7f64 --- /dev/null +++ b/tools/stac-vscode/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Stac + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/tools/stac-vscode/README.md b/tools/stac-vscode/README.md new file mode 100644 index 000000000..31b9837cd --- /dev/null +++ b/tools/stac-vscode/README.md @@ -0,0 +1,67 @@ +# Stac — Server-Driven UI for Flutter + +Build and preview Server-Driven UI screens with the **Stac** framework — directly inside VS Code. + +## ✨ Features + +### 🔴 Live Preview +Open a side-by-side preview of any `@StacScreen` — updates on save, supports theme selection, and renders with Android/iOS/Web platform simulation. + +![Stac Live Preview](media/stac_preview.png) + +- **`Stac: Open Preview`** — launch the preview panel for the active screen +- **Device toggles** — switch between Android, iOS, and Web viewports +- **Theme picker** — select any `@StacThemeRef` theme to preview with + +### 🔧 Wrap Quick Fixes +Place your cursor on any Stac widget expression and press **Cmd+.** to wrap it: + +- `StacContainer`, `StacPadding`, `StacCenter`, `StacAlign`, `StacSizedBox`, `StacExpanded` +- **Wrap with Stac widget…** — type any Stac widget class name + +### 📝 Snippets +Type in a Stac DSL context (files containing `@StacScreen`, `@StacThemeRef`, or `package:stac_core`): + +- `stac screen` — new screen template +- `stac theme` — new theme template + +## ⚙️ Extension Settings + +| Setting | Default | Description | +|---|---|---| +| `stacVscode.enableWrapQuickFix` | `true` | Enable wrap quick-fix actions | +| `stacVscode.wrapPresets` | All presets | Preset wrappers in quick-fix menu | +| `stacVscode.enableSnippets` | `true` | Enable `stac screen`/`stac theme` snippets | +| `stacVscode.preview.enable` | `true` | Enable preview commands | +| `stacVscode.preview.autoRefreshOnSave` | `true` | Refresh preview on save | +| `stacVscode.preview.jsonStrategy` | `runnerThenBuild` | JSON generation strategy | +| `stacVscode.preview.hostPort` | `47841` | Local preview host port | +| `stacVscode.preview.startupTimeoutMs` | `120000` | Host startup timeout | + +## Requirements + +- **Flutter SDK** with Dart `3.9.2+` +- A Flutter project using the [Stac](https://stac.dev) framework + +## Commands + +| Command | Description | +|---|---| +| `Stac: Open Preview` | Open live preview panel | +| `Stac: Select Preview Screen` | Switch to a different screen in the current file | +| `Stac: Stop Preview` | Stop the preview host | +| `Stac: Regenerate Catalog` | Rebuild widget catalog from `stac_core` | + +## Troubleshooting + +If the preview doesn't start, open **Output → Stac Preview** for detailed logs. + +## Links + +- [Stac Documentation](https://stac.dev) +- [GitHub Repository](https://github.com/StacDev/stac) +- [Report Issues](https://github.com/StacDev/stac/issues) + +## License + +[MIT](LICENSE) diff --git a/tools/stac-vscode/eslint.config.mjs b/tools/stac-vscode/eslint.config.mjs new file mode 100644 index 000000000..b2860950d --- /dev/null +++ b/tools/stac-vscode/eslint.config.mjs @@ -0,0 +1,28 @@ +import typescriptEslint from "typescript-eslint"; + +export default [{ + files: ["**/*.ts"], +}, { + files: ["**/*.ts"], + plugins: { + "@typescript-eslint": typescriptEslint.plugin, + }, + + languageOptions: { + parser: typescriptEslint.parser, + ecmaVersion: 2022, + sourceType: "module", + }, + + rules: { + "@typescript-eslint/naming-convention": ["warn", { + selector: "import", + format: ["camelCase", "PascalCase"], + }], + + curly: "warn", + eqeqeq: "warn", + "no-throw-literal": "warn", + semi: "warn", + }, +}]; \ No newline at end of file diff --git a/tools/stac-vscode/media/icon.png b/tools/stac-vscode/media/icon.png new file mode 100644 index 000000000..ee3e9f9ad Binary files /dev/null and b/tools/stac-vscode/media/icon.png differ diff --git a/tools/stac-vscode/media/preview-icon-light.svg b/tools/stac-vscode/media/preview-icon-light.svg new file mode 100644 index 000000000..b5e68ee83 --- /dev/null +++ b/tools/stac-vscode/media/preview-icon-light.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/tools/stac-vscode/media/preview-icon.svg b/tools/stac-vscode/media/preview-icon.svg new file mode 100644 index 000000000..bf61bdcf5 --- /dev/null +++ b/tools/stac-vscode/media/preview-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/tools/stac-vscode/media/preview-open-dark.svg b/tools/stac-vscode/media/preview-open-dark.svg new file mode 100644 index 000000000..b461ebfe3 --- /dev/null +++ b/tools/stac-vscode/media/preview-open-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/tools/stac-vscode/media/preview-open-light.svg b/tools/stac-vscode/media/preview-open-light.svg new file mode 100644 index 000000000..f6e4b17b4 --- /dev/null +++ b/tools/stac-vscode/media/preview-open-light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/tools/stac-vscode/media/preview-refresh-dark.svg b/tools/stac-vscode/media/preview-refresh-dark.svg new file mode 100644 index 000000000..cc589c161 --- /dev/null +++ b/tools/stac-vscode/media/preview-refresh-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/tools/stac-vscode/media/preview-refresh-light.svg b/tools/stac-vscode/media/preview-refresh-light.svg new file mode 100644 index 000000000..648a8c20e --- /dev/null +++ b/tools/stac-vscode/media/preview-refresh-light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/tools/stac-vscode/media/stac_preview.png b/tools/stac-vscode/media/stac_preview.png new file mode 100644 index 000000000..a4062c2ff Binary files /dev/null and b/tools/stac-vscode/media/stac_preview.png differ diff --git a/tools/stac-vscode/package-lock.json b/tools/stac-vscode/package-lock.json new file mode 100644 index 000000000..04d107089 --- /dev/null +++ b/tools/stac-vscode/package-lock.json @@ -0,0 +1,3190 @@ +{ + "name": "stac-vscode", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "stac-vscode", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "js-yaml": "^4.1.1" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/mocha": "^10.0.10", + "@types/node": "22.x", + "@types/vscode": "^1.80.0", + "@vscode/test-cli": "^0.0.12", + "@vscode/test-electron": "^2.5.2", + "eslint": "^9.39.2", + "typescript": "^5.9.3", + "typescript-eslint": "^8.54.0" + }, + "engines": { + "vscode": "^1.80.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mocha": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", + "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.109.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.109.0.tgz", + "integrity": "sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz", + "integrity": "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/type-utils": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.55.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz", + "integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.55.0.tgz", + "integrity": "sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.55.0", + "@typescript-eslint/types": "^8.55.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz", + "integrity": "sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz", + "integrity": "sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz", + "integrity": "sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.55.0.tgz", + "integrity": "sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz", + "integrity": "sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.55.0", + "@typescript-eslint/tsconfig-utils": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/visitor-keys": "8.55.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.55.0.tgz", + "integrity": "sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.55.0", + "@typescript-eslint/types": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz", + "integrity": "sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.55.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vscode/test-cli": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@vscode/test-cli/-/test-cli-0.0.12.tgz", + "integrity": "sha512-iYN0fDg29+a2Xelle/Y56Xvv7Nc8Thzq4VwpzAF/SIE6918rDicqfsQxV6w1ttr2+SOm+10laGuY9FG2ptEKsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mocha": "^10.0.10", + "c8": "^10.1.3", + "chokidar": "^3.6.0", + "enhanced-resolve": "^5.18.3", + "glob": "^10.3.10", + "minimatch": "^9.0.3", + "mocha": "^11.7.4", + "supports-color": "^10.2.2", + "yargs": "^17.7.2" + }, + "bin": { + "vscode-test": "out/bin.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@vscode/test-electron": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/@vscode/test-electron/-/test-electron-2.5.2.tgz", + "integrity": "sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.5", + "jszip": "^3.10.1", + "ora": "^8.1.0", + "semver": "^7.6.2" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "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/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/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/c8": { + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/c8/-/c8-10.1.3.tgz", + "integrity": "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^7.0.1", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/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/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-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.4.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", + "integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mocha": { + "version": "11.7.5", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-11.7.5.tgz", + "integrity": "sha512-mTT6RgopEYABzXWFx+GcJ+ZQ32kp4fMf0xvpZIIfSq9Z8lC/++MtcCnQ9t5FP2veYEP95FIYSvW+U9fV4xrlig==", + "dev": true, + "license": "MIT", + "dependencies": { + "browser-stdout": "^1.3.1", + "chokidar": "^4.0.1", + "debug": "^4.3.5", + "diff": "^7.0.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^10.4.5", + "he": "^1.2.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^9.0.5", + "ms": "^2.1.3", + "picocolors": "^1.1.1", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^9.2.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/mocha/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/mocha/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/mocha/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/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz", + "integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "cli-cursor": "^5.0.0", + "cli-spinners": "^2.9.2", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^2.0.0", + "log-symbols": "^6.0.0", + "stdin-discarder": "^0.2.2", + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ora/node_modules/is-unicode-supported": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/log-symbols": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz", + "integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "is-unicode-supported": "^1.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/log-symbols/node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "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/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/stdin-discarder": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", + "integrity": "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/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/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/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/typescript-eslint": { + "version": "8.55.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.55.0.tgz", + "integrity": "sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.55.0", + "@typescript-eslint/parser": "8.55.0", + "@typescript-eslint/typescript-estree": "8.55.0", + "@typescript-eslint/utils": "8.55.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/workerpool": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", + "integrity": "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "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-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/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-cjs/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/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/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/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/tools/stac-vscode/package.json b/tools/stac-vscode/package.json new file mode 100644 index 000000000..6a5d6555c --- /dev/null +++ b/tools/stac-vscode/package.json @@ -0,0 +1,210 @@ +{ + "name": "stac-vscode", + "displayName": "Stac", + "description": "Build Server-Driven UI with Stac — live preview, wrap quick-fixes, and snippets for Flutter.", + "version": "0.1.0", + "publisher": "StacDev", + "license": "MIT", + "icon": "media/icon.png", + "homepage": "https://stac.dev", + "bugs": { + "url": "https://github.com/StacDev/stac/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/StacDev/stac.git", + "directory": "tools/stac-vscode" + }, + "galleryBanner": { + "color": "#0B0B0D", + "theme": "dark" + }, + "engines": { + "vscode": "^1.80.0" + }, + "categories": [ + "Programming Languages", + "Snippets", + "Other" + ], + "keywords": [ + "stac", + "flutter", + "dart", + "server-driven-ui", + "preview" + ], + "activationEvents": [ + "onLanguage:dart" + ], + "main": "./out/extension.js", + "contributes": { + "commands": [ + { + "command": "stac-vscode.wrapWithStacContainer", + "title": "Wrap with StacContainer" + }, + { + "command": "stac-vscode.wrapWithStacPadding", + "title": "Wrap with StacPadding" + }, + { + "command": "stac-vscode.wrapWithStacCenter", + "title": "Wrap with StacCenter" + }, + { + "command": "stac-vscode.wrapWithStacAlign", + "title": "Wrap with StacAlign" + }, + { + "command": "stac-vscode.wrapWithStacSizedBox", + "title": "Wrap with StacSizedBox" + }, + { + "command": "stac-vscode.wrapWithStacExpanded", + "title": "Wrap with StacExpanded" + }, + { + "command": "stac-vscode.wrapWithStacWidget", + "title": "Wrap with Stac widget" + }, + { + "command": "stac-vscode.regenerateCatalog", + "title": "Stac: Regenerate Catalog" + }, + { + "command": "stac-vscode.preview.open", + "title": "Stac: Open Preview", + "icon": { + "light": "media/preview-icon-light.svg", + "dark": "media/preview-icon.svg" + } + }, + { + "command": "stac-vscode.preview.stop", + "title": "Stac: Stop Preview" + }, + { + "command": "stac-vscode.preview.selectScreen", + "title": "Stac: Select Preview Screen" + } + ], + "configuration": { + "title": "Stac VS Code", + "properties": { + "stacVscode.enableWrapQuickFix": { + "type": "boolean", + "default": true, + "description": "Enable Cmd+. Stac wrap quick fixes when cursor is on a Stac widget expression." + }, + "stacVscode.wrapPresets": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "StacContainer", + "StacPadding", + "StacCenter", + "StacAlign", + "StacSizedBox", + "StacExpanded" + ] + }, + "default": [ + "StacContainer", + "StacPadding", + "StacCenter", + "StacAlign", + "StacSizedBox", + "StacExpanded" + ], + "description": "Preset wrappers to display in quick-fix menu." + }, + "stacVscode.enableSnippets": { + "type": "boolean", + "default": true, + "description": "Enable `stac screen` and `stac theme` snippets in Stac DSL contexts." + }, + "stacVscode.preview.enable": { + "type": "boolean", + "default": true, + "description": "Enable the Stac right-side preview panel commands." + }, + "stacVscode.preview.autoRefreshOnSave": { + "type": "boolean", + "default": true, + "description": "Automatically refresh Stac preview on Dart file save when preview is open." + }, + "stacVscode.preview.jsonStrategy": { + "type": "string", + "enum": [ + "runnerThenBuild", + "runnerOnly", + "buildOnly" + ], + "default": "runnerThenBuild", + "description": "JSON generation strategy for preview: use direct runner first with build fallback, runner only, or build only." + }, + "stacVscode.preview.buildCommand": { + "type": "string", + "default": "stac build --project \"${projectFolder}\"", + "description": "Build command used for preview fallback mode. Supports ${projectFolder} and ${workspaceFolder} tokens." + }, + "stacVscode.preview.outputDirCandidates": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "stac/.build", + "build/screens", + "build" + ], + "description": "Candidate output directories searched for .json after fallback build." + }, + "stacVscode.preview.hostPort": { + "type": "number", + "default": 47841, + "description": "Port used by the local Flutter preview host." + }, + "stacVscode.preview.startupTimeoutMs": { + "type": "number", + "default": 120000, + "description": "Timeout in milliseconds for starting the preview host." + } + } + }, + "menus": { + "editor/title": [ + { + "command": "stac-vscode.preview.open", + "when": "resourceLangId == dart && config.stacVscode.preview.enable", + "group": "navigation@1" + } + ] + } + }, + "scripts": { + "vscode:prepublish": "npm run compile", + "generate:catalog": "node ./scripts/generate-catalog.mjs", + "compile": "npm run generate:catalog && tsc -p ./", + "watch": "tsc -watch -p ./", + "pretest": "npm run compile && npm run lint", + "lint": "eslint src", + "test": "vscode-test" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/mocha": "^10.0.10", + "@types/node": "22.x", + "@types/vscode": "^1.80.0", + "@vscode/test-cli": "^0.0.12", + "@vscode/test-electron": "^2.5.2", + "eslint": "^9.39.2", + "typescript": "^5.9.3", + "typescript-eslint": "^8.54.0" + }, + "dependencies": { + "js-yaml": "^4.1.1" + } +} diff --git a/tools/stac-vscode/preview_host/lib/main.dart b/tools/stac-vscode/preview_host/lib/main.dart new file mode 100644 index 000000000..11dc8e8f1 --- /dev/null +++ b/tools/stac-vscode/preview_host/lib/main.dart @@ -0,0 +1,319 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:js_interop'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:stac/stac.dart'; +import 'package:web/web.dart' as web; +import 'package:http/http.dart' as http; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await Stac.initialize(); + runApp(const _PreviewApp()); +} + +class _PreviewApp extends StatefulWidget { + const _PreviewApp(); + + @override + State<_PreviewApp> createState() => _PreviewAppState(); +} + +class _PreviewAppState extends State<_PreviewApp> { + Map? _json; + Map? _themeJson; + String? _requestId; + TargetPlatform? _targetPlatform; + Timer? _readyPingTimer; + bool _receivedFirstPayload = false; + JSFunction? _onMessageJs; + + @override + void initState() { + super.initState(); + _log('Preview host initState'); + _onMessageJs = _onMessage.toJS; + web.window.addEventListener('message', _onMessageJs!); + _announceReady(); + _readyPingTimer = Timer.periodic(const Duration(seconds: 2), (timer) { + if (_receivedFirstPayload || !mounted || timer.tick >= 10) { + timer.cancel(); + return; + } + _announceReady(); + }); + } + + @override + void dispose() { + if (_onMessageJs != null) { + web.window.removeEventListener('message', _onMessageJs!); + _onMessageJs = null; + } + _readyPingTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + debugShowCheckedModeBanner: false, + themeAnimationDuration: Duration.zero, + theme: + _buildThemeData(context) ?? + (_targetPlatform != null + ? ThemeData(platform: _targetPlatform) + : null), + home: _json == null + ? const Scaffold( + body: SizedBox.shrink(), + ) // Hide loader - webview shows progress bar instead + : KeyedSubtree( + key: ValueKey(_requestId), + child: Scaffold( + body: Builder( + builder: (context) { + final widget = Stac.fromJson(_json, context); + if (widget == null) { + return const Center( + child: Text( + 'Unable to render preview.', + style: TextStyle(color: Colors.red), + ), + ); + } + return widget; + }, + ), + ), + ), + ); + } + + ThemeData? _cachedThemeData; + Map? _lastThemeJson; + TargetPlatform? _lastTargetPlatform; + + ThemeData? _buildThemeData(BuildContext context) { + if (_themeJson == null && _targetPlatform == null) { + _cachedThemeData = null; + _lastThemeJson = null; + _lastTargetPlatform = null; + return null; + } + + if (_themeJson == _lastThemeJson && + _targetPlatform == _lastTargetPlatform) { + return _cachedThemeData; + } + + try { + ThemeData? themeData; + if (_themeJson != null) { + final stacTheme = StacTheme.fromJson(_themeJson!); + themeData = stacTheme.parse(context); + } + + if (_targetPlatform != null) { + themeData = (themeData ?? ThemeData.light()).copyWith( + platform: _targetPlatform, + ); + } + + _lastThemeJson = _themeJson; + _lastTargetPlatform = _targetPlatform; + _cachedThemeData = themeData; + return themeData; + } catch (_) { + return null; + } + } + + void _onMessage(web.MessageEvent event) { + _log('Received message event'); + final message = _normalize(event.data.dartify()); + if (message == null) { + _log('Failed to normalize message'); + return; + } + + final type = message['type']; + _log('Message type: $type'); + + // Handle platform change + if (type == 'stac.preview.setPlatform') { + final platform = message['platform'] as String?; + final tp = switch (platform) { + 'android' => TargetPlatform.android, + 'ios' => TargetPlatform.iOS, + _ => null, + }; + // Override the global platform so ALL widgets respect it + debugDefaultTargetPlatformOverride = tp; + setState(() { + _targetPlatform = tp; + }); + return; + } + + if (type == 'stac.preview.loadFonts') { + _loadFonts(message['fonts']); + return; + } + + if (type != 'stac.preview.render') return; + + try { + _receivedFirstPayload = true; + _readyPingTimer?.cancel(); + + final payload = message['json']; + if (payload is! Map) { + throw const FormatException('Payload json must be an object.'); + } + + final json = _deepCast(payload); + final screenName = (message['screenName'] as String?) ?? 'screen'; + final requestId = message['requestId']?.toString(); + _log('Processing render payload for $screenName, requestId: $requestId'); + + // Parse optional theme + Map? themeJson; + final themePayload = message['theme']; + if (themePayload is Map) { + themeJson = _deepCast(themePayload); + } + + setState(() { + _json = json; + _themeJson = themeJson; + _requestId = requestId; + }); + _log('State updated with new JSON'); + + _post({ + 'type': 'stac.preview.rendered', + 'message': 'Rendered $screenName.', + 'screenName': screenName, + 'requestId': requestId, + }); + } catch (error) { + _log('Preview host error: $error'); + _post({ + 'type': 'stac.preview.error', + 'message': 'Preview host failed: $error', + 'requestId': message['requestId']?.toString(), + }); + } + } + + Future _loadFonts(dynamic fontsPayload) async { + if (fontsPayload is! List) return; + + final futures = >[]; + + for (final fontData in fontsPayload) { + if (fontData is! Map) continue; + final family = fontData['family'] as String?; + final urls = fontData['urls'] as List?; + + if (family == null || urls == null) continue; + + futures.add(_loadFontFamily(family, urls)); + } + + await Future.wait(futures); + + // Trigger rebuild to apply new fonts + if (mounted) setState(() {}); + } + + Future _loadFontFamily(String family, List urls) async { + final loader = FontLoader(family); + for (final url in urls) { + if (url is String) { + loader.addFont(_fetchFont(url)); + } + } + try { + await loader.load(); + _log('Loaded font family: $family'); + } catch (e) { + _log('Failed to load font family $family: $e'); + } + } + + Future _fetchFont(String url) async { + try { + final response = await http + .get(Uri.parse(url)) + .timeout(const Duration(seconds: 10)); + if (response.statusCode == 200) { + return ByteData.view(response.bodyBytes.buffer); + } else { + throw Exception( + 'Failed to load font from $url: ${response.statusCode}', + ); + } + } on TimeoutException { + throw Exception('Timed out loading font from $url'); + } catch (e) { + throw Exception('Failed to load font from $url: $e'); + } + } + + void _announceReady() { + _post({ + 'type': 'stac.preview.ready', + 'message': 'Flutter preview host ready.', + }); + } + + void _log(String message) { + _post({'type': 'stac.preview.log', 'message': message}); + } + + void _post(Map payload) { + final parent = web.window.parent; + if (parent != null) { + parent.postMessage(jsonEncode(payload).toJS, '*'.toJS); + } + } + + Map? _normalize(dynamic raw) { + dynamic decoded = raw; + if (raw is String) { + try { + decoded = jsonDecode(raw); + } catch (_) { + return null; + } + } + if (decoded is Map) { + return _deepCast(decoded); + } + return null; + } + + Map _deepCast(Map input) { + return input.map((key, value) { + final k = key.toString(); + if (value is Map) return MapEntry(k, _deepCast(value)); + if (value is List) return MapEntry(k, _deepCastList(value)); + return MapEntry(k, value); + }); + } + + List _deepCastList(List input) { + return input + .map((item) { + if (item is Map) return _deepCast(item); + if (item is List) return _deepCastList(item); + return item; + }) + .toList(growable: false); + } +} diff --git a/tools/stac-vscode/preview_host/pubspec.lock b/tools/stac-vscode/preview_host/pubspec.lock new file mode 100644 index 000000000..44c66b936 --- /dev/null +++ b/tools/stac-vscode/preview_host/pubspec.lock @@ -0,0 +1,605 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + cached_network_image: + dependency: transitive + description: + name: cached_network_image + sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + cached_network_image_platform_interface: + dependency: transitive + description: + name: cached_network_image_platform_interface + sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829" + url: "https://pub.dev" + source: hosted + version: "4.1.1" + cached_network_image_web: + dependency: transitive + description: + name: cached_network_image_web + sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + dio: + dependency: transitive + description: + name: dio + sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25 + url: "https://pub.dev" + source: hosted + version: "5.9.1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c + url: "https://pub.dev" + source: hosted + version: "2.1.5" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_cache_manager: + dependency: transitive + description: + name: flutter_cache_manager + sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386" + url: "https://pub.dev" + source: hosted + version: "3.4.1" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" + url: "https://pub.dev" + source: hosted + version: "2.2.3" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" + url: "https://pub.dev" + source: hosted + version: "4.10.0" + logger: + dependency: transitive + description: + name: logger + sha256: a7967e31b703831a893bbc3c3dd11db08126fe5f369b5c648a36f821979f5be3 + url: "https://pub.dev" + source: hosted + version: "2.6.2" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + url: "https://pub.dev" + source: hosted + version: "0.17.4" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + octo_image: + dependency: transitive + description: + name: octo_image + sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.dev" + source: hosted + version: "7.0.1" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f + url: "https://pub.dev" + source: hosted + version: "2.4.20" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + sqflite: + dependency: transitive + description: + name: sqflite + sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_android: + dependency: transitive + description: + name: sqflite_android + sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88 + url: "https://pub.dev" + source: hosted + version: "2.4.2+2" + sqflite_common: + dependency: transitive + description: + name: sqflite_common + sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + sqflite_darwin: + dependency: transitive + description: + name: sqflite_darwin + sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + sqflite_platform_interface: + dependency: transitive + description: + name: sqflite_platform_interface + sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + stac: + dependency: "direct main" + description: + name: stac + sha256: c9386fef72e8d0cde31b690366bd13933f0c2c0ea7c0694224379c87bc3d4984 + url: "https://pub.dev" + source: hosted + version: "1.3.1" + stac_core: + dependency: transitive + description: + name: stac_core + sha256: "090d62de85aa6c779bd0681849d2f9b3cb2fe6e476996e7f143c611bd53ba842" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + stac_framework: + dependency: transitive + description: + name: stac_framework + sha256: e75d1a1b2fd46c65acbc6ce174c7272aef4eb4020722f09c35fce2b3dadf858e + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stac_logger: + dependency: transitive + description: + name: stac_logger + sha256: bc3c1cc486d59d2378c1e18bfd9bfa078be564b58d4ae2b3898633c05a02df26 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 + url: "https://pub.dev" + source: hosted + version: "3.4.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 + url: "https://pub.dev" + source: hosted + version: "4.5.2" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 + url: "https://pub.dev" + source: hosted + version: "1.1.19" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b" + url: "https://pub.dev" + source: hosted + version: "1.1.20" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + web: + dependency: "direct main" + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.dev" + source: hosted + version: "6.6.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/tools/stac-vscode/preview_host/pubspec.yaml b/tools/stac-vscode/preview_host/pubspec.yaml new file mode 100644 index 000000000..f85e1a542 --- /dev/null +++ b/tools/stac-vscode/preview_host/pubspec.yaml @@ -0,0 +1,18 @@ +name: stac_vscode_preview_host +description: Local Flutter web renderer for stac-vscode preview panel. +publish_to: 'none' + +version: 0.1.0+1 + +environment: + sdk: ^3.9.2 + +dependencies: + flutter: + sdk: flutter + http: ^1.6.0 + stac: ^1.3.1 + web: any + +flutter: + uses-material-design: true diff --git a/tools/stac-vscode/preview_host/web/index.html b/tools/stac-vscode/preview_host/web/index.html new file mode 100644 index 000000000..a5f7687c9 --- /dev/null +++ b/tools/stac-vscode/preview_host/web/index.html @@ -0,0 +1,16 @@ + + + + + + + + Stac Preview Host + + + + + diff --git a/tools/stac-vscode/scripts/generate-catalog.mjs b/tools/stac-vscode/scripts/generate-catalog.mjs new file mode 100644 index 000000000..e5d39edcb --- /dev/null +++ b/tools/stac-vscode/scripts/generate-catalog.mjs @@ -0,0 +1,77 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const extensionRoot = path.resolve(process.cwd()); +const repoRoot = path.resolve(extensionRoot, '..', '..'); +const stacCoreRoot = path.join(repoRoot, 'packages', 'stac_core', 'lib'); + +const widgetsExportPath = path.join(stacCoreRoot, 'widgets', 'widgets.dart'); +const widgetCatalogOut = path.join(extensionRoot, 'src', 'generated', 'widgetCatalog.ts'); + +const toTs = (value) => JSON.stringify(value, null, 2); + +async function readText(filePath) { + return fs.readFile(filePath, 'utf8'); +} + +async function parseWidgetCatalog() { + const exportText = await readText(widgetsExportPath); + const exportMatches = [...exportText.matchAll(/^export '(.+?)';$/gm)]; + + const widgets = []; + + for (const match of exportMatches) { + const exportRel = match[1]; + const slugMatch = exportRel.match(/stac_([^/]+)\.dart$/); + if (!slugMatch) { + continue; + } + + const slug = slugMatch[1]; + const widgetFile = path.join(stacCoreRoot, 'widgets', exportRel); + const widgetSource = await readText(widgetFile); + + const classMatch = widgetSource.match(/class\s+(Stac[A-Za-z0-9_]+)\s+extends\s+StacWidget/); + if (!classMatch) { + continue; + } + + const className = classMatch[1]; + const ctorRegex = new RegExp(`const\\s+${className}\\s*\\(\\s*\\{([\\s\\S]*?)\\}\\s*\\);`); + const ctorMatch = widgetSource.match(ctorRegex); + const ctorBody = ctorMatch ? ctorMatch[1] : ''; + + const supportsChild = /\bthis\.child\b/.test(ctorBody); + const supportsChildren = /\bthis\.children\b/.test(ctorBody); + + widgets.push({ + className, + slug, + supportsChild, + supportsChildren, + }); + } + + widgets.sort((a, b) => a.className.localeCompare(b.className)); + return widgets; +} + +async function writeWidgetCatalog(widgets) { + const payload = `/* AUTO-GENERATED FILE. DO NOT EDIT. */\n\nexport interface WidgetCatalogEntry {\n className: string;\n slug: string;\n supportsChild: boolean;\n supportsChildren: boolean;\n}\n\nexport const widgetCatalog: WidgetCatalogEntry[] = ${toTs(widgets)};\n\nexport const widgetCatalogByClass = new Map(\n widgetCatalog.map((entry) => [entry.className, entry] as const),\n);\n`; + + await fs.writeFile(widgetCatalogOut, payload); +} + +async function main() { + const widgets = await parseWidgetCatalog(); + await writeWidgetCatalog(widgets); + + // eslint-disable-next-line no-console + console.log(`Generated ${widgets.length} widgets for wrap/snippet support.`); +} + +main().catch((error) => { + // eslint-disable-next-line no-console + console.error(error); + process.exitCode = 1; +}); diff --git a/tools/stac-vscode/src/core/constants.ts b/tools/stac-vscode/src/core/constants.ts new file mode 100644 index 000000000..5d4a14ad9 --- /dev/null +++ b/tools/stac-vscode/src/core/constants.ts @@ -0,0 +1,41 @@ +export const EXTENSION_ID = 'stac-vscode'; + +export const COMMANDS = { + wrapWithStacContainer: 'stac-vscode.wrapWithStacContainer', + wrapWithStacPadding: 'stac-vscode.wrapWithStacPadding', + wrapWithStacCenter: 'stac-vscode.wrapWithStacCenter', + wrapWithStacAlign: 'stac-vscode.wrapWithStacAlign', + wrapWithStacSizedBox: 'stac-vscode.wrapWithStacSizedBox', + wrapWithStacExpanded: 'stac-vscode.wrapWithStacExpanded', + wrapWithStacWidget: 'stac-vscode.wrapWithStacWidget', + regenerateCatalog: 'stac-vscode.regenerateCatalog', + previewOpen: 'stac-vscode.preview.open', + previewRefresh: 'stac-vscode.preview.refresh', + previewStop: 'stac-vscode.preview.stop', + previewSelectScreen: 'stac-vscode.preview.selectScreen', + removeStacWidget: 'stac-vscode.removeStacWidget', +} as const; + +export const SETTINGS = { + enableWrapQuickFix: 'stacVscode.enableWrapQuickFix', + wrapPresets: 'stacVscode.wrapPresets', + enableSnippets: 'stacVscode.enableSnippets', + previewEnable: 'stacVscode.preview.enable', + previewAutoRefreshOnSave: 'stacVscode.preview.autoRefreshOnSave', + previewJsonStrategy: 'stacVscode.preview.jsonStrategy', + previewBuildCommand: 'stacVscode.preview.buildCommand', + previewOutputDirCandidates: 'stacVscode.preview.outputDirCandidates', + previewHostPort: 'stacVscode.preview.hostPort', + previewStartupTimeoutMs: 'stacVscode.preview.startupTimeoutMs', +} as const; + +export const WRAP_PRESET_IDS = [ + 'StacContainer', + 'StacPadding', + 'StacCenter', + 'StacAlign', + 'StacSizedBox', + 'StacExpanded', +] as const; + +export type WrapPresetId = (typeof WRAP_PRESET_IDS)[number]; diff --git a/tools/stac-vscode/src/core/isStacDslDocument.ts b/tools/stac-vscode/src/core/isStacDslDocument.ts new file mode 100644 index 000000000..dbe9e88c3 --- /dev/null +++ b/tools/stac-vscode/src/core/isStacDslDocument.ts @@ -0,0 +1,19 @@ +import * as vscode from 'vscode'; + +const STAC_ANNOTATION_REGEX = /@(StacScreen|StacThemeRef)\b/; +const STAC_IMPORT_REGEX = /package:stac_core\/stac_core\.dart/; +const STAC_PATH_SEGMENT_REGEX = /(^|\/)stac(\/|$)/; + +export function isStacDslDocument(document: vscode.TextDocument): boolean { + if (document.languageId !== 'dart') { + return false; + } + + const normalizedPath = document.uri.fsPath.replace(/\\/g, '/'); + if (STAC_PATH_SEGMENT_REGEX.test(normalizedPath)) { + return true; + } + + const text = document.getText(); + return STAC_ANNOTATION_REGEX.test(text) || STAC_IMPORT_REGEX.test(text); +} diff --git a/tools/stac-vscode/src/extension.ts b/tools/stac-vscode/src/extension.ts new file mode 100644 index 000000000..3a8fc951e --- /dev/null +++ b/tools/stac-vscode/src/extension.ts @@ -0,0 +1,191 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import { COMMANDS, WRAP_PRESET_IDS } from './core/constants'; +import { PreviewManager } from './preview/previewManager'; +import { StacSnippetCompletionProvider } from './snippets/stacSnippetCompletionProvider'; +import { applyWrapWorkspaceEdit } from './wrap/applyWrapEdit'; +import { createRemoveWidgetEdit } from './wrap/removeWidget'; +import { findWrappableExpression } from './wrap/findWrappableExpression'; +import { StacWrapCodeActionProvider } from './wrap/stacWrapCodeActionProvider'; +import { + CUSTOM_WIDGET_PLACEHOLDER_TEMPLATE, + getPresetWrapper, +} from './wrap/wrapperTemplates'; + +const execFileAsync = promisify(execFile); +let previewManager: PreviewManager | undefined; + +export function activate(context: vscode.ExtensionContext) { + registerWrapCodeActions(context); + registerSnippets(context); + registerWrapCommands(context); + registerRegenerateCatalogCommand(context); + previewManager = new PreviewManager(context); + previewManager.register(); +} + +function registerWrapCodeActions(context: vscode.ExtensionContext) { + const provider = new StacWrapCodeActionProvider(); + context.subscriptions.push( + vscode.languages.registerCodeActionsProvider('dart', provider, { + providedCodeActionKinds: [vscode.CodeActionKind.QuickFix], + }), + ); +} + +function registerSnippets(context: vscode.ExtensionContext) { + const provider = new StacSnippetCompletionProvider(); + context.subscriptions.push( + vscode.languages.registerCompletionItemProvider('dart', provider, ' '), + ); +} + +function registerWrapCommands(context: vscode.ExtensionContext) { + const commandByPreset = { + StacContainer: COMMANDS.wrapWithStacContainer, + StacPadding: COMMANDS.wrapWithStacPadding, + StacCenter: COMMANDS.wrapWithStacCenter, + StacAlign: COMMANDS.wrapWithStacAlign, + StacSizedBox: COMMANDS.wrapWithStacSizedBox, + StacExpanded: COMMANDS.wrapWithStacExpanded, + } as const; + + for (const preset of WRAP_PRESET_IDS) { + const command = commandByPreset[preset]; + const template = getPresetWrapper(preset); + if (!template) { + continue; + } + + const disposable = vscode.commands.registerCommand( + command, + async (uri?: vscode.Uri, range?: vscode.Range) => { + const contextTarget = await resolveWrapTarget(uri, range); + if (!contextTarget) { + return; + } + + if (contextTarget.target.widgetName === template.wrapperName) { + return; + } + + await applyWrapWorkspaceEdit( + contextTarget.document, + contextTarget.target, + template, + ); + }, + ); + + context.subscriptions.push(disposable); + } + + const customDisposable = vscode.commands.registerCommand( + COMMANDS.wrapWithStacWidget, + async (uri?: vscode.Uri, range?: vscode.Range) => { + const contextTarget = await resolveWrapTarget(uri, range); + if (!contextTarget) { + return; + } + + const { document, target } = contextTarget; + const applied = await applyWrapWorkspaceEdit( + document, + target, + CUSTOM_WIDGET_PLACEHOLDER_TEMPLATE, + ); + if (!applied) { + return; + } + + // Select "StacWidget" so user can type the widget/class name inline (Flutter-style) + const placeholderLength = CUSTOM_WIDGET_PLACEHOLDER_TEMPLATE.wrapperName.length; + const selection = new vscode.Range( + target.range.start, + target.range.start.translate(0, placeholderLength), + ); + const editor = await vscode.window.showTextDocument(document.uri, { + selection, + preserveFocus: false, + }); + editor.revealRange(selection); + }, + ); + context.subscriptions.push(customDisposable); + + const removeDisposable = vscode.commands.registerCommand( + COMMANDS.removeStacWidget, + async (uri?: vscode.Uri, range?: vscode.Range) => { + const contextTarget = await resolveWrapTarget(uri, range); + if (!contextTarget) { + return; + } + + const { document, target } = contextTarget; + const edit = createRemoveWidgetEdit(document, target); + if (!edit) { + return; + } + + await vscode.workspace.applyEdit(edit); + }, + ); + context.subscriptions.push(removeDisposable); +} + +async function resolveWrapTarget(uri?: vscode.Uri, range?: vscode.Range) { + if (uri) { + const document = await vscode.workspace.openTextDocument(uri); + const targetRange = range ?? new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 0)); + const target = findWrappableExpression(document, targetRange); + + if (!target) { + return undefined; + } + + return { document, target }; + } + + const editor = vscode.window.activeTextEditor; + if (!editor) { + return undefined; + } + + const target = findWrappableExpression(editor.document, editor.selection); + if (!target) { + return undefined; + } + + return { document: editor.document, target }; +} + +function registerRegenerateCatalogCommand(context: vscode.ExtensionContext) { + const disposable = vscode.commands.registerCommand( + COMMANDS.regenerateCatalog, + async () => { + const scriptPath = path.join(context.extensionPath, 'scripts', 'generate-catalog.mjs'); + + try { + await execFileAsync(process.execPath, [scriptPath], { + cwd: context.extensionPath, + }); + void vscode.window.showInformationMessage('Stac catalog regenerated.'); + } catch (error) { + void vscode.window.showErrorMessage( + `Failed to regenerate Stac catalog: ${String(error)}`, + ); + } + }, + ); + + context.subscriptions.push(disposable); +} + +export async function deactivate() { + if (previewManager) { + await previewManager.dispose(); + previewManager = undefined; + } +} diff --git a/tools/stac-vscode/src/generated/widgetCatalog.ts b/tools/stac-vscode/src/generated/widgetCatalog.ts new file mode 100644 index 000000000..103fc19d7 --- /dev/null +++ b/tools/stac-vscode/src/generated/widgetCatalog.ts @@ -0,0 +1,561 @@ +/* AUTO-GENERATED FILE. DO NOT EDIT. */ + +export interface WidgetCatalogEntry { + className: string; + slug: string; + supportsChild: boolean; + supportsChildren: boolean; +} + +export const widgetCatalog: WidgetCatalogEntry[] = [ + { + "className": "StacAlertDialog", + "slug": "alert_dialog", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacAlign", + "slug": "align", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacAppBar", + "slug": "app_bar", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacAspectRatio", + "slug": "aspect_ratio", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacAutoComplete", + "slug": "auto_complete", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacBackdropFilter", + "slug": "backdrop_filter", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacBadge", + "slug": "badge", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacBottomNavigationBar", + "slug": "bottom_navigation_bar", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacBottomNavigationView", + "slug": "bottom_navigation_view", + "supportsChild": false, + "supportsChildren": true + }, + { + "className": "StacCard", + "slug": "card", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacCarouselView", + "slug": "carousel_view", + "supportsChild": false, + "supportsChildren": true + }, + { + "className": "StacCenter", + "slug": "center", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacCheckBox", + "slug": "check_box", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacChip", + "slug": "chip", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacCircleAvatar", + "slug": "circle_avatar", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacCircularProgressIndicator", + "slug": "circular_progress_indicator", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacClipOval", + "slug": "clip_oval", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacClipRRect", + "slug": "clip_rrect", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacColoredBox", + "slug": "colored_box", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacColumn", + "slug": "column", + "supportsChild": false, + "supportsChildren": true + }, + { + "className": "StacConditional", + "slug": "conditional", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacContainer", + "slug": "container", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacCustomScrollView", + "slug": "custom_scroll_view", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacDefaultBottomNavigationController", + "slug": "default_bottom_navigation_controller", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacDefaultTabController", + "slug": "default_tab_controller", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacDivider", + "slug": "divider", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacDrawer", + "slug": "drawer", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacDropdownMenu", + "slug": "dropdown_menu", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacDynamicView", + "slug": "dynamic_view", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacElevatedButton", + "slug": "elevated_button", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacExpanded", + "slug": "expanded", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacFilledButton", + "slug": "filled_button", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacFittedBox", + "slug": "fitted_box", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacFlexible", + "slug": "flexible", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacFloatingActionButton", + "slug": "floating_action_button", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacForm", + "slug": "form", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacFractionallySizedBox", + "slug": "fractionally_sized_box", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacGestureDetector", + "slug": "gesture_detector", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacGridView", + "slug": "grid_view", + "supportsChild": false, + "supportsChildren": true + }, + { + "className": "StacHero", + "slug": "hero", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacIcon", + "slug": "icon", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacIconButton", + "slug": "icon_button", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacImage", + "slug": "image", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacInkWell", + "slug": "ink_well", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacLimitedBox", + "slug": "limited_box", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacLinearProgressIndicator", + "slug": "linear_progress_indicator", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacListTile", + "slug": "list_tile", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacListView", + "slug": "list_view", + "supportsChild": false, + "supportsChildren": true + }, + { + "className": "StacNetworkWidget", + "slug": "network_widget", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacOpacity", + "slug": "opacity", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacOutlinedButton", + "slug": "outlined_button", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacPadding", + "slug": "padding", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacPageView", + "slug": "page_view", + "supportsChild": false, + "supportsChildren": true + }, + { + "className": "StacPlaceholder", + "slug": "placeholder", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacPositioned", + "slug": "positioned", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacRadio", + "slug": "radio", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacRadioGroup", + "slug": "radio_group", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacRefreshIndicator", + "slug": "refresh_indicator", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacRow", + "slug": "row", + "supportsChild": false, + "supportsChildren": true + }, + { + "className": "StacSafeArea", + "slug": "safe_area", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacScaffold", + "slug": "scaffold", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacSelectableText", + "slug": "selectable_text", + "supportsChild": false, + "supportsChildren": true + }, + { + "className": "StacSetValue", + "slug": "set_value", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacSingleChildScrollView", + "slug": "single_child_scroll_view", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacSizedBox", + "slug": "sized_box", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacSlider", + "slug": "slider", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacSliverAppBar", + "slug": "sliver_app_bar", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacSliverFillRemaining", + "slug": "sliver_fill_remaining", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacSliverGrid", + "slug": "sliver_grid", + "supportsChild": false, + "supportsChildren": true + }, + { + "className": "StacSliverList", + "slug": "sliver_list", + "supportsChild": false, + "supportsChildren": true + }, + { + "className": "StacSliverOpacity", + "slug": "sliver_opacity", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacSliverPadding", + "slug": "sliver_padding", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacSliverSafeArea", + "slug": "sliver_safe_area", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacSliverToBoxAdapter", + "slug": "sliver_to_box_adapter", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacSliverVisibility", + "slug": "sliver_visibility", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacSpacer", + "slug": "spacer", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacStack", + "slug": "stack", + "supportsChild": false, + "supportsChildren": true + }, + { + "className": "StacSwitch", + "slug": "switch", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacTab", + "slug": "tab", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacTabBar", + "slug": "tab_bar", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacTabBarView", + "slug": "tab_bar_view", + "supportsChild": false, + "supportsChildren": true + }, + { + "className": "StacTable", + "slug": "table", + "supportsChild": false, + "supportsChildren": true + }, + { + "className": "StacTableCell", + "slug": "table_cell", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacText", + "slug": "text", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacTextButton", + "slug": "text_button", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacTextField", + "slug": "text_field", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacTextFormField", + "slug": "text_form_field", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacTooltip", + "slug": "tool_tip", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacVerticalDivider", + "slug": "vertical_divider", + "supportsChild": false, + "supportsChildren": false + }, + { + "className": "StacVisibility", + "slug": "visibility", + "supportsChild": true, + "supportsChildren": false + }, + { + "className": "StacWrap", + "slug": "wrap", + "supportsChild": false, + "supportsChildren": true + } +]; + +export const widgetCatalogByClass = new Map( + widgetCatalog.map((entry) => [entry.className, entry] as const), +); diff --git a/tools/stac-vscode/src/preview/assetServer.ts b/tools/stac-vscode/src/preview/assetServer.ts new file mode 100644 index 000000000..034cb8ecb --- /dev/null +++ b/tools/stac-vscode/src/preview/assetServer.ts @@ -0,0 +1,115 @@ + +import * as http from 'node:http'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { findAvailablePort } from './utils'; + +export class AssetServer { + private server?: http.Server; + private _port?: number; + + constructor(private readonly logger: (message: string) => void) { } + + get port(): number | undefined { + return this._port; + } + + async start(workspaceRoot: string): Promise { + if (this.server) { + return this._port!; + } + + const port = await findAvailablePort(8000, 100); + if (!port) { + throw new Error('No free port found for asset server'); + } + + this._port = port; + this.server = http.createServer((req, res) => { + // Enable CORS + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.writeHead(405); + res.end(); + return; + } + + try { + const url = new URL(req.url ?? '', `http://127.0.0.1:${port}`); + + // Construct file path and resolve it to absolute path + const requestPath = url.pathname.replace(/^\/+/, ''); + const filePath = path.resolve(workspaceRoot, requestPath); + + // Verify the resolved path is inside workspaceRoot + const relativePath = path.relative(workspaceRoot, filePath); + if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) { + res.writeHead(403); + res.end('Forbidden: outside workspace boundaries'); + return; + } + + if (!fs.existsSync(filePath)) { + res.writeHead(404); + res.end('Not found'); + return; + } + + const stat = fs.statSync(filePath); + if (!stat.isFile()) { + res.writeHead(403); + res.end('Not a file'); + return; + } + + const ext = path.extname(filePath).toLowerCase(); + let contentType = 'application/octet-stream'; + if (ext === '.png') contentType = 'image/png'; + else if (ext === '.jpg' || ext === '.jpeg') contentType = 'image/jpeg'; + else if (ext === '.gif') contentType = 'image/gif'; + else if (ext === '.svg') contentType = 'image/svg+xml'; + else if (ext === '.json') contentType = 'application/json'; + else if (ext === '.ttf') contentType = 'font/ttf'; + else if (ext === '.otf') contentType = 'font/otf'; + else if (ext === '.woff') contentType = 'font/woff'; + else if (ext === '.woff2') contentType = 'font/woff2'; + + res.setHeader('Content-Type', contentType); + res.setHeader('Content-Length', stat.size); + + const stream = fs.createReadStream(filePath); + stream.pipe(res); + } catch (e) { + this.logger(`[assetServer] Error: ${e}`); + res.writeHead(500); + res.end('Internal Server Error'); + } + }); + + return new Promise((resolve, reject) => { + this.server!.listen(port, '127.0.0.1', () => { + resolve(port); + }); + this.server!.on('error', (err) => { + reject(err); + }); + }); + } + + stop() { + if (this.server) { + this.server.close(); + this.server = undefined; + this._port = undefined; + } + } +} diff --git a/tools/stac-vscode/src/preview/buildFallback.ts b/tools/stac-vscode/src/preview/buildFallback.ts new file mode 100644 index 000000000..1e20d2e75 --- /dev/null +++ b/tools/stac-vscode/src/preview/buildFallback.ts @@ -0,0 +1,86 @@ +import { spawn } from 'node:child_process'; +import * as vscode from 'vscode'; +import { readJsonFile, resolveScreenJsonPath } from './jsonResolver'; + +export interface BuildFallbackOptions { + workspaceRoot: string; + screenName: string; + buildCommand: string; + outputDirCandidates: readonly string[]; + outputChannel: vscode.OutputChannel; +} + +export interface BuildFallbackResult { + json: Record; + jsonPath: string; +} + +export async function runBuildFallback( + options: BuildFallbackOptions, +): Promise { + const { workspaceRoot, screenName, buildCommand, outputDirCandidates, outputChannel } = options; + outputChannel.appendLine(`[preview] Running build fallback: ${buildCommand}`); + + const commandResult = await runShellCommand(buildCommand, workspaceRoot, outputChannel); + if (commandResult.exitCode !== 0) { + throw new Error( + `Build command failed (exit ${commandResult.exitCode}): ${buildCommand}`, + ); + } + + const jsonPath = resolveScreenJsonPath(workspaceRoot, screenName, outputDirCandidates); + if (!jsonPath) { + // Debug info: reconstruct tried paths + const fs = await import('node:fs'); + const path = await import('node:path'); + const tried = outputDirCandidates.map(c => { + const base = path.isAbsolute(c) ? c : path.join(workspaceRoot, c); + return path.join(base, `${screenName}.json`); + }); + outputChannel.appendLine(`[preview] Failed to find JSON. Checked paths:\n${tried.map(p => ` - ${p} (exists: ${fs.existsSync(p)})`).join('\n')}`); + + throw new Error(`Unable to find ${screenName}.json after build fallback.`); + } + + const json = await readJsonFile(jsonPath); + return { + json, + jsonPath, + }; +} + +interface ShellCommandResult { + exitCode: number; +} + +function runShellCommand( + command: string, + cwd: string, + outputChannel: vscode.OutputChannel, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, { + cwd, + shell: true, + env: process.env, + }); + + child.stdout.on('data', (chunk: Buffer | string) => { + outputChannel.append(chunk.toString()); + }); + + child.stderr.on('data', (chunk: Buffer | string) => { + outputChannel.append(chunk.toString()); + }); + + child.on('error', (error) => { + reject(error); + }); + + child.on('close', (code) => { + resolve({ + exitCode: code ?? 1, + }); + }); + }); +} diff --git a/tools/stac-vscode/src/preview/fontDiscovery.ts b/tools/stac-vscode/src/preview/fontDiscovery.ts new file mode 100644 index 000000000..df148d2b6 --- /dev/null +++ b/tools/stac-vscode/src/preview/fontDiscovery.ts @@ -0,0 +1,45 @@ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; + +export interface FontDefinition { + family: string; + fonts: { asset: string }[]; +} + +interface Pubspec { + flutter?: { + fonts?: { + family: string; + fonts: { asset: string }[]; + }[]; + }; +} + +export async function findFontsInPubspec(projectRoot: string): Promise { + const pubspecPath = path.join(projectRoot, 'pubspec.yaml'); + if (!fs.existsSync(pubspecPath)) { + return []; + } + + try { + const content = fs.readFileSync(pubspecPath, 'utf8'); + const pubspec = yaml.load(content) as Pubspec; + const fonts = pubspec.flutter?.fonts; + + if (!Array.isArray(fonts)) { + return []; + } + + // Filter and validate structure + return fonts.filter(f => + typeof f.family === 'string' && + Array.isArray(f.fonts) && + f.fonts.every(asset => typeof asset.asset === 'string') + ); + } catch (error) { + console.error('Failed to parse pubspec.yaml for fonts:', error); + return []; + } +} diff --git a/tools/stac-vscode/src/preview/jsonGeneration.ts b/tools/stac-vscode/src/preview/jsonGeneration.ts new file mode 100644 index 000000000..9bf004164 --- /dev/null +++ b/tools/stac-vscode/src/preview/jsonGeneration.ts @@ -0,0 +1,151 @@ +import { spawn } from 'node:child_process'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import { runBuildFallback, type BuildFallbackOptions, type BuildFallbackResult } from './buildFallback'; +import { readJsonFile } from './jsonResolver'; +import { writeRunnerArtifacts } from './runnerScript'; +import type { JsonGenerationResult, PreviewJsonStrategy } from './types'; + +export interface JsonGenerationOptions { + workspaceRoot: string; + sourceFilePath: string; + screenName: string; + functionName: string; + runnerSupported: boolean; + strategy: PreviewJsonStrategy; + buildCommand: string; + outputDirCandidates: readonly string[]; + outputChannel: vscode.OutputChannel; +} + +export class RunnerError extends Error { + constructor(message: string) { + super(message); + this.name = 'RunnerError'; + } +} + +interface JsonGenerationDeps { + runRunner: (options: JsonGenerationOptions) => Promise; + runBuildFallback: (options: BuildFallbackOptions) => Promise; +} + +const defaultDeps: JsonGenerationDeps = { + runRunner: runRunnerFastPath, + runBuildFallback, +}; + +export async function generatePreviewJson( + options: JsonGenerationOptions, + deps: JsonGenerationDeps = defaultDeps, +): Promise { + if (options.strategy === 'buildOnly') { + options.outputChannel.appendLine('[preview] Strategy buildOnly selected.'); + return runFallback(options, deps); + } + + try { + options.outputChannel.appendLine('[preview] Strategy using runner fast path.'); + return await deps.runRunner(options); + } catch (error) { + if (options.strategy === 'runnerOnly') { + throw error; + } + + options.outputChannel.appendLine( + `[preview] Runner failed, switching to build fallback: ${String(error)}`, + ); + return runFallback(options, deps); + } +} + +async function runRunnerFastPath( + options: JsonGenerationOptions, +): Promise { + if (!options.runnerSupported) { + throw new RunnerError( + 'Selected screen is not supported by direct runner. It must be top-level and zero-argument.', + ); + } + + const artifacts = await writeRunnerArtifacts( + options.workspaceRoot, + options.sourceFilePath, + options.functionName, + options.screenName, + ); + + options.outputChannel.appendLine( + `[preview] Running runner fast path: ${artifacts.scriptPath}`, + ); + // Use relative path from workspace root so dart run can resolve packages correctly + const relativeScriptPath = path.relative(options.workspaceRoot, artifacts.scriptPath); + const runnerCommand = ['run', relativeScriptPath, artifacts.outputPath]; + const result = await runCommand('dart', runnerCommand, options.workspaceRoot, options.outputChannel); + if (result.exitCode !== 0) { + throw new RunnerError(`Runner command failed (exit ${result.exitCode}).`); + } + + const json = await readJsonFile(artifacts.outputPath); + return { + source: 'runner', + json, + jsonPath: artifacts.outputPath, + }; +} + +async function runFallback( + options: JsonGenerationOptions, + deps: JsonGenerationDeps, +): Promise { + const fallback = await deps.runBuildFallback({ + workspaceRoot: options.workspaceRoot, + screenName: options.screenName, + buildCommand: options.buildCommand, + outputDirCandidates: options.outputDirCandidates, + outputChannel: options.outputChannel, + }); + + return { + source: 'build', + json: fallback.json, + jsonPath: fallback.jsonPath, + }; +} + +interface CommandResult { + exitCode: number; +} + +function runCommand( + command: string, + args: readonly string[], + cwd: string, + outputChannel: vscode.OutputChannel, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, [...args], { + cwd, + env: process.env, + shell: process.platform === 'win32', + }); + + child.stdout.on('data', (chunk: Buffer | string) => { + outputChannel.append(chunk.toString()); + }); + + child.stderr.on('data', (chunk: Buffer | string) => { + outputChannel.append(chunk.toString()); + }); + + child.on('error', (error) => { + reject(error); + }); + + child.on('close', (code) => { + resolve({ + exitCode: code ?? 1, + }); + }); + }); +} diff --git a/tools/stac-vscode/src/preview/jsonResolver.ts b/tools/stac-vscode/src/preview/jsonResolver.ts new file mode 100644 index 000000000..1d3525fac --- /dev/null +++ b/tools/stac-vscode/src/preview/jsonResolver.ts @@ -0,0 +1,41 @@ +import { existsSync, promises as fs } from 'node:fs'; +import * as path from 'node:path'; + +export function resolveScreenJsonPath( + workspaceRoot: string, + screenName: string, + outputDirCandidates: readonly string[], +): string | undefined { + for (const candidate of outputDirCandidates) { + const expanded = expandWorkspacePathTokens(candidate, workspaceRoot); + const baseDir = path.isAbsolute(expanded) ? expanded : path.join(workspaceRoot, expanded); + const jsonPath = path.join(baseDir, `${screenName}.json`); + if (fileExistsSync(jsonPath)) { + return jsonPath; + } + } + + return undefined; +} + +export async function readJsonFile(jsonPath: string): Promise> { + const raw = await fs.readFile(jsonPath, 'utf8'); + const decoded = JSON.parse(raw) as unknown; + if (!isRecord(decoded)) { + throw new Error(`JSON root is not an object: ${jsonPath}`); + } + + return decoded; +} + +export function expandWorkspacePathTokens(pathValue: string, workspaceRoot: string): string { + return pathValue.replaceAll('${workspaceFolder}', workspaceRoot); +} + +function fileExistsSync(filePath: string): boolean { + return existsSync(filePath); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/tools/stac-vscode/src/preview/jsonTransformer.ts b/tools/stac-vscode/src/preview/jsonTransformer.ts new file mode 100644 index 000000000..9c93ce57e --- /dev/null +++ b/tools/stac-vscode/src/preview/jsonTransformer.ts @@ -0,0 +1,35 @@ + +export function transformJson(json: any, assetServerPort: number): any { + if (json === null || typeof json !== 'object') { + return json; + } + + if (Array.isArray(json)) { + return json.map((item) => transformJson(item, assetServerPort)); + } + + // Check for StacImage with asset type + // We assume the structure matches the StacImage definition in the catalog/schema. + // Typically: { "type": "image", "imageType": "asset", "src": "assets/foo.png", ... } + const result: any = {}; + for (const key in json) { + if (Object.prototype.hasOwnProperty.call(json, key)) { + result[key] = transformJson(json[key], assetServerPort); + } + } + + if ( + json.type === 'image' && + json.imageType === 'asset' && + typeof json.src === 'string' + ) { + // Transform to network image pointing to local asset server + result.imageType = 'network'; + // Ensure src doesn't start with leading slash for clean joining, or handle it. + // The asset server expects path from workspace root. + // If src is "assets/icon.png", url is "http://127.0.0.1:PORT/assets/icon.png" + result.src = `http://127.0.0.1:${assetServerPort}/${json.src.replace(/^\//, '')}`; + } + + return result; +} diff --git a/tools/stac-vscode/src/preview/previewHostProcess.ts b/tools/stac-vscode/src/preview/previewHostProcess.ts new file mode 100644 index 000000000..18e652440 --- /dev/null +++ b/tools/stac-vscode/src/preview/previewHostProcess.ts @@ -0,0 +1,353 @@ +import { existsSync } from 'node:fs'; +import * as http from 'node:http'; +import * as net from 'node:net'; +import * as path from 'node:path'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import * as vscode from 'vscode'; +import { canBindPort, findAvailablePort } from './utils'; + +export interface PreviewHostProcessOptions { + extensionPath: string; + outputChannel: vscode.OutputChannel; + port: number; + startupTimeoutMs: number; +} + +export class PreviewHostProcess { + private readonly extensionPath: string; + + private readonly outputChannel: vscode.OutputChannel; + + private port: number; + + private readonly startupTimeoutMs: number; + + private readonly hostDir: string; + + private process?: ChildProcessWithoutNullStreams; + + private startupPromise?: Promise; + + private hostOutputBuffer = ''; + + private lastExitCode?: number; + + constructor(options: PreviewHostProcessOptions) { + this.extensionPath = options.extensionPath; + this.outputChannel = options.outputChannel; + this.port = options.port; + this.startupTimeoutMs = options.startupTimeoutMs; + this.hostDir = path.join(this.extensionPath, 'preview_host'); + } + + get hostUrl(): string { + return `http://127.0.0.1:${this.port}`; + } + + get hostPort(): number { + return this.port; + } + + async ensureStarted(): Promise { + if (await this.isHealthy()) { + return this.hostUrl; + } + + if (this.startupPromise) { + return this.startupPromise; + } + + this.startupPromise = this.startInternal(); + try { + return await this.startupPromise; + } finally { + this.startupPromise = undefined; + } + } + + async stop(): Promise { + if (!this.process) { + return; + } + + const running = this.process; + this.process = undefined; + running.kill('SIGTERM'); + + // Wait for the process to exit so the port is actually released + await new Promise((resolve) => { + const timeout = setTimeout(() => { + running.kill('SIGKILL'); + resolve(); + }, 5000); + + running.on('close', () => { + clearTimeout(timeout); + resolve(); + }); + }); + } + + private async startInternal(): Promise { + await this.ensurePreviewHostDependencies(); + const maxPortRetries = 10; + + // Pre-check: if the initial port is busy, find a free one BEFORE spawning + // flutter (avoids the slow fail-then-retry cycle) + if (!(await canBindPort(this.port))) { + this.outputChannel.appendLine( + `[preview] Port ${this.port} is already in use, finding a free port...`, + ); + const freePort = await findAvailablePort(this.port + 1, 30); + if (freePort !== undefined) { + this.port = freePort; + } else { + this.outputChannel.appendLine(`[preview] No free port found after port ${this.port}. Aborting startup.`); + throw new Error(`Preview host port ${this.port} is busy and no free port was found.`); + } + } + + for (let attempt = 0; attempt <= maxPortRetries; attempt += 1) { + this.outputChannel.appendLine( + `[preview] Starting Flutter preview host on port ${this.port}...`, + ); + this.hostOutputBuffer = ''; + this.lastExitCode = undefined; + const command = 'flutter'; + this.process = spawn( + command, + [ + 'run', + '-d', + 'web-server', + '--web-port', + String(this.port), + '--web-hostname', + '127.0.0.1', + '--target', + 'lib/main.dart', + ], + { + cwd: this.hostDir, + env: process.env, + shell: process.platform === 'win32', + }, + ); + + this.process.stdout.on('data', (chunk: Buffer | string) => { + const text = chunk.toString(); + this.appendHostOutput(text); + this.outputChannel.append(text); + }); + + this.process.stderr.on('data', (chunk: Buffer | string) => { + const text = chunk.toString(); + this.appendHostOutput(text); + this.outputChannel.append(text); + }); + + this.process.on('close', (code) => { + this.lastExitCode = code ?? 0; + this.outputChannel.appendLine( + `[preview] Flutter preview host exited with code ${code ?? 0}.`, + ); + this.process = undefined; + }); + + this.process.on('error', (error) => { + this.outputChannel.appendLine(`[preview] Flutter preview host error: ${String(error)}`); + this.process = undefined; + }); + + try { + await this.waitForHostHealthy(); + this.outputChannel.appendLine('[preview] Flutter preview host is ready.'); + return this.hostUrl; + } catch (error) { + const detail = `${String(error)} ${this.hostOutputBuffer}`; + if (!isAddressInUseError(detail)) { + throw error; + } + + this.outputChannel.appendLine( + `[preview] Port ${this.port} is in use. Trying a new port...`, + ); + await this.stop(); + const nextPort = await findAvailablePort(this.port + 1, 30); + if (nextPort === undefined) { + throw new Error( + `Preview host port ${this.port} is busy and no free port was found.`, + ); + } + this.port = nextPort; + } + } + + throw new Error('Preview host failed to start after multiple port retries.'); + } + + private async ensurePreviewHostDependencies(): Promise { + const packageConfigPath = path.join( + this.hostDir, + '.dart_tool', + 'package_config.json', + ); + if (existsSync(packageConfigPath)) { + return; + } + + this.outputChannel.appendLine('[preview] Running flutter pub get for preview host...'); + const result = await runCommand( + 'flutter', + ['pub', 'get'], + this.hostDir, + this.outputChannel, + ); + + if (result.exitCode !== 0) { + const excerpt = summarizeOutput(result.output); + throw new Error( + [ + `flutter pub get failed for preview host (exit ${result.exitCode}).`, + 'Check Stac Preview output channel for full logs.', + 'Ensure Flutter SDK includes Dart 3.9.2+.', + excerpt ? `Last output: ${excerpt}` : '', + ] + .filter((line) => line.length > 0) + .join(' '), + ); + } + } + + private async isHealthy(): Promise { + const target = `${this.hostUrl}/`; + return new Promise((resolve) => { + const request = http.get(target, (response) => { + response.resume(); + resolve((response.statusCode ?? 500) < 500); + }); + + request.on('error', () => resolve(false)); + request.setTimeout(1200, () => { + request.destroy(); + resolve(false); + }); + }); + } + + private async waitForHostHealthy(): Promise { + const startedAt = Date.now(); + // The HTTP health check alone is insufficient: Flutter's web-server starts + // responding before Dart-to-JS compilation finishes, so the iframe would + // load an incomplete page. Wait for Flutter's stdout to confirm the app is + // actually being served before declaring the host ready. + const servingPattern = 'is being served at'; + let stdoutPatternSeen = false; + + while (Date.now() - startedAt < this.startupTimeoutMs) { + if (!this.process) { + const excerpt = summarizeOutput(this.hostOutputBuffer); + throw new Error( + [ + 'Preview host exited before becoming healthy.', + this.lastExitCode !== undefined ? `Exit code ${this.lastExitCode}.` : '', + excerpt ? `Last output: ${excerpt}` : '', + ] + .filter((line) => line.length > 0) + .join(' '), + ); + } + + if (!stdoutPatternSeen) { + stdoutPatternSeen = this.hostOutputBuffer.includes(servingPattern); + } + + if (stdoutPatternSeen && await this.isHealthy()) { + return; + } + + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + } + + const excerpt = summarizeOutput(this.hostOutputBuffer); + throw new Error( + [ + `Preview host startup timed out after ${this.startupTimeoutMs}ms.`, + excerpt ? `Last output: ${excerpt}` : '', + ] + .filter((line) => line.length > 0) + .join(' '), + ); + } + + private appendHostOutput(text: string) { + this.hostOutputBuffer += text; + if (this.hostOutputBuffer.length > 16000) { + this.hostOutputBuffer = this.hostOutputBuffer.slice(-16000); + } + } +} + +interface CommandResult { + exitCode: number; + output: string; +} + +function runCommand( + command: string, + args: readonly string[], + cwd: string, + outputChannel: vscode.OutputChannel, +): Promise { + return new Promise((resolve, reject) => { + let buffered = ''; + + const child = spawn(command, [...args], { + cwd, + env: process.env, + shell: process.platform === 'win32', + }); + + child.stdout.on('data', (chunk: Buffer | string) => { + const text = chunk.toString(); + buffered += text; + outputChannel.append(text); + }); + + child.stderr.on('data', (chunk: Buffer | string) => { + const text = chunk.toString(); + buffered += text; + outputChannel.append(text); + }); + + child.on('error', (error) => reject(error)); + child.on('close', (code) => { + resolve({ + exitCode: code ?? 1, + output: buffered, + }); + }); + }); +} + +function summarizeOutput(output: string): string { + const lines = output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); + if (lines.length === 0) { + return ''; + } + + return lines.slice(-4).join(' | '); +} + +function isAddressInUseError(detail: string): boolean { + return detail.includes('Address already in use') + || detail.includes('EADDRINUSE') + || detail.includes('errno = 48'); +} + + diff --git a/tools/stac-vscode/src/preview/previewManager.ts b/tools/stac-vscode/src/preview/previewManager.ts new file mode 100644 index 000000000..51e590825 --- /dev/null +++ b/tools/stac-vscode/src/preview/previewManager.ts @@ -0,0 +1,1032 @@ +import * as vscode from 'vscode'; +import { existsSync, statSync } from 'node:fs'; +import * as path from 'node:path'; +import { spawn } from 'node:child_process'; +import { COMMANDS, SETTINGS } from '../core/constants'; +import { AssetServer } from './assetServer'; +import { findFontsInPubspec } from './fontDiscovery'; +import { generatePreviewJson } from './jsonGeneration'; +import { readJsonFile } from './jsonResolver'; +import { transformJson } from './jsonTransformer'; +import { PreviewHostProcess } from './previewHostProcess'; +import { PreviewPanel } from './previewPanel'; +import { + chooseScreenDescriptor, + discoverScreens, + pickScreenDescriptor, +} from './screenDiscovery'; +import { discoverThemesInWorkspace } from './themeDiscovery'; +import { writeThemeRunnerArtifacts } from './runnerScript'; +import type { + PreviewJsonStrategy, + PreviewOutboundMessage, + PreviewRenderMessage, + PreviewWebviewMessage, + ThemeDescriptor, +} from './types'; + +interface PreviewSettings { + enabled: boolean; + autoRefreshOnSave: boolean; + strategy: PreviewJsonStrategy; + buildCommand: string; + outputDirCandidates: string[]; + hostPort: number; + startupTimeoutMs: number; +} + +export class PreviewManager implements vscode.Disposable { + private readonly context: vscode.ExtensionContext; + + private readonly outputChannel: vscode.OutputChannel; + + private panel?: PreviewPanel; + + private panelHostPort?: number; + + private hostProcess?: PreviewHostProcess; + + private assetServer?: AssetServer; + + private lastAssetRoot?: string; + + private hostSettingsKey?: string; + + private activeDocumentUri?: vscode.Uri; + + private readonly preferredScreenByDocument = new Map(); + + private lastRenderMessage?: PreviewRenderMessage; + + private lastRequestedScreenName?: string; + + private lastRenderRequestId?: string; + + private discoveredThemes: ThemeDescriptor[] = []; + + private selectedThemeName?: string; + + private themeJsonCache = new Map>(); + + private readonly resolvedProjectRoots = new Set(); + + private pendingRefresh?: { + uri: vscode.Uri; + cursorOffset?: number; + }; + + private refreshRunning = false; + + /** Timestamp of the last explicit refresh (openPreview / refreshPreview). Used to suppress + * duplicate renders from handleDidChangeActiveEditor / handleDidChangeSelection that fire + * concurrently with the explicit refresh. */ + private lastExplicitRefreshTime = 0; + + constructor(context: vscode.ExtensionContext) { + this.context = context; + this.outputChannel = vscode.window.createOutputChannel('Stac Preview'); + } + + register() { + this.context.subscriptions.push( + vscode.commands.registerCommand(COMMANDS.previewOpen, async () => { + await this.openPreview(); + }), + vscode.commands.registerCommand(COMMANDS.previewRefresh, async () => { + await this.refreshPreview(); + }), + vscode.commands.registerCommand(COMMANDS.previewStop, async () => { + await this.stopPreview(); + }), + vscode.commands.registerCommand(COMMANDS.previewSelectScreen, async () => { + await this.selectScreen(); + }), + vscode.workspace.onDidSaveTextDocument((document) => { + void this.handleDidSaveDocument(document); + }), + vscode.window.onDidChangeActiveTextEditor((editor) => { + void this.handleDidChangeActiveEditor(editor); + }), + vscode.window.onDidChangeTextEditorSelection((event) => { + void this.handleDidChangeSelection(event); + }), + this.outputChannel, + this, + ); + } + + async openPreview(): Promise { + const settings = this.getSettings(); + if (!settings.enabled) { + void vscode.window.showInformationMessage( + 'Stac preview is disabled by stacVscode.preview.enable.', + ); + return; + } + + const editor = vscode.window.activeTextEditor; + if (!editor || editor.document.languageId !== 'dart') { + void vscode.window.showErrorMessage('Open a Dart file containing @StacScreen to preview.'); + return; + } + + this.activeDocumentUri = editor.document.uri; + this.preferredScreenByDocument.delete(editor.document.uri.fsPath); + + // Show the panel immediately so the user sees the loading state right away. + // Use the configured port — if it turns out to be busy, ensurePanelAndHost + // will recreate the panel with the actual port once the host is ready. + this.createOrUpdatePanel( + `http://127.0.0.1:${settings.hostPort}`, + settings.hostPort, + ); + + // Eagerly trigger host startup — runs in background while we discover themes + // and prepare content. refreshDocument() will await the host when it needs the panel. + this.warmUpHost(settings); + + await this.refreshThemeList(); + + this.lastExplicitRefreshTime = Date.now(); + this.enqueueRefresh(editor.document.uri); + } + + async refreshPreview(): Promise { + const settings = this.getSettings(); + if (!settings.enabled) { + return; + } + + const editor = vscode.window.activeTextEditor; + if (editor && editor.document.languageId === 'dart') { + this.activeDocumentUri = editor.document.uri; + const cursorOffset = editor.document.offsetAt(editor.selection.active); + this.lastExplicitRefreshTime = Date.now(); + this.enqueueRefresh(editor.document.uri, cursorOffset); + return; + } + + if (this.activeDocumentUri) { + this.enqueueRefresh(this.activeDocumentUri); + return; + } + + void vscode.window.showErrorMessage('No active Stac screen document to refresh.'); + } + + async stopPreview(): Promise { + this.pendingRefresh = undefined; + this.refreshRunning = false; + + if (this.panel) { + this.panel.dispose(); + this.panel = undefined; + this.panelHostPort = undefined; + } + + if (this.hostProcess) { + await this.hostProcess.stop(); + this.hostProcess = undefined; + this.hostSettingsKey = undefined; + } + + if (this.assetServer) { + this.assetServer.stop(); + this.assetServer = undefined; + this.lastAssetRoot = undefined; + } + } + + async selectScreen(): Promise { + const editor = vscode.window.activeTextEditor; + if (!editor || editor.document.languageId !== 'dart') { + void vscode.window.showErrorMessage('Open a Dart document to select a preview screen.'); + return; + } + + const screens = discoverScreens(editor.document); + if (screens.length === 0) { + void vscode.window.showErrorMessage('No @StacScreen declarations found in this file.'); + return; + } + + const selected = await pickScreenDescriptor(screens); + if (!selected) { + return; + } + + this.preferredScreenByDocument.set(editor.document.uri.fsPath, selected.screenName); + this.activeDocumentUri = editor.document.uri; + const cursorOffset = editor.document.offsetAt(editor.selection.active); + this.enqueueRefresh(editor.document.uri, cursorOffset); + } + + async dispose(): Promise { + await this.stopPreview(); + } + + private async handleDidSaveDocument(document: vscode.TextDocument): Promise { + const settings = this.getSettings(); + if (!settings.enabled || !settings.autoRefreshOnSave) { + return; + } + + if (!this.panel || !this.activeDocumentUri) { + return; + } + + const fsPath = document.uri.fsPath; + + // pubspec.yaml changes can affect fonts, assets, and package resolution — refresh the preview + if (fsPath.endsWith('pubspec.yaml')) { + this.outputChannel.appendLine( + '[preview] pubspec.yaml saved, refreshing preview for asset/font/package changes.', + ); + const projectRoot = this.resolveProjectRoot(document); + if (projectRoot) { + this.resolvedProjectRoots.delete(projectRoot); + } + this.enqueueRefresh(this.activeDocumentUri); + return; + } + + if (document.languageId !== 'dart') { + return; + } + + // Invalidate theme cache if the saved file is a known theme source + const isKnownThemeFile = this.discoveredThemes.some( + (t) => t.filePath === fsPath, + ); + if (isKnownThemeFile) { + for (const theme of this.discoveredThemes) { + if (theme.filePath === fsPath) { + this.themeJsonCache.delete(theme.themeName); + } + } + this.outputChannel.appendLine( + `[preview] Theme file saved, cache invalidated: ${fsPath}`, + ); + } + + // Re-discover themes if the saved file contains @StacThemeRef (new or existing) + // to pick up newly added themes without a full restart. + const fileText = document.getText(); + const mightContainTheme = fileText.includes('@StacThemeRef'); + let hasNewThemes = false; + if (mightContainTheme || isKnownThemeFile) { + const previousNames = new Set(this.discoveredThemes.map((t) => t.themeName)); + await this.refreshThemeList(); + hasNewThemes = this.discoveredThemes.some((t) => !previousNames.has(t.themeName)); + } + + const isActiveScreen = fsPath === this.activeDocumentUri.fsPath; + const isThemeFileNow = this.discoveredThemes.some( + (t) => t.filePath === fsPath, + ); + + if (!isActiveScreen && !isThemeFileNow && !hasNewThemes) { + return; + } + + const editor = vscode.window.activeTextEditor; + const cursorOffset = editor && editor.document.uri.fsPath === this.activeDocumentUri.fsPath + ? editor.document.offsetAt(editor.selection.active) + : undefined; + + this.enqueueRefresh(this.activeDocumentUri, cursorOffset); + } + + private async handleDidChangeActiveEditor( + editor: vscode.TextEditor | undefined, + ): Promise { + const settings = this.getSettings(); + if (!settings.enabled) { + return; + } + + // Suppress if an explicit refresh (open/refresh command) was triggered very recently + if (Date.now() - this.lastExplicitRefreshTime < 2000) { + return; + } + + if (!this.panel) { + return; + } + + if (!editor) { + return; + } + + const { document } = editor; + if (document.languageId !== 'dart') { + return; + } + + const screens = discoverScreens(document); + if (screens.length === 0) { + return; + } + + this.activeDocumentUri = document.uri; + const cursorOffset = document.offsetAt(editor.selection.active); + this.enqueueRefresh(document.uri, cursorOffset); + } + + private async handleDidChangeSelection( + event: vscode.TextEditorSelectionChangeEvent, + ): Promise { + const settings = this.getSettings(); + if (!settings.enabled || !this.panel) { + return; + } + + const { document } = event.textEditor; + if (document.languageId !== 'dart') { + return; + } + + const screens = discoverScreens(document); + if (screens.length === 0) { + return; + } + + // Suppress if an explicit refresh was triggered very recently + if (Date.now() - this.lastExplicitRefreshTime < 2000) { + return; + } + + const selection = event.selections[0]; + if (!selection) { + return; + } + + const cursorOffset = document.offsetAt(selection.active); + const preferred = this.preferredScreenByDocument.get(document.uri.fsPath); + const target = chooseScreenDescriptor(screens, cursorOffset, preferred); + if (!target) { + return; + } + + if ( + this.activeDocumentUri?.fsPath === document.uri.fsPath + && this.lastRequestedScreenName === target.screenName + ) { + return; + } + + this.activeDocumentUri = document.uri; + this.preferredScreenByDocument.set(document.uri.fsPath, target.screenName); + this.enqueueRefresh(document.uri, cursorOffset); + } + + private enqueueRefresh(uri: vscode.Uri, cursorOffset?: number) { + this.pendingRefresh = { uri, cursorOffset }; + if (!this.refreshRunning) { + void this.runRefreshLoop(); + } + } + + private async runRefreshLoop(): Promise { + this.refreshRunning = true; + while (this.pendingRefresh) { + const next = this.pendingRefresh; + this.pendingRefresh = undefined; + + try { + await this.refreshDocument(next.uri, next.cursorOffset); + } catch (error) { + this.outputChannel.appendLine(`[preview] Refresh failed: ${String(error)}`); + this.outputChannel.show(true); + if (this.panel) { + void this.panel.postState('error', `Preview refresh failed: ${String(error)}`); + } + void vscode.window.showErrorMessage(`Stac preview failed: ${String(error)}`); + } + } + this.refreshRunning = false; + } + + private async refreshDocument( + documentUri: vscode.Uri, + cursorOffset?: number, + ): Promise { + const settings = this.getSettings(); + if (!settings.enabled) { + return; + } + + const document = await vscode.workspace.openTextDocument(documentUri); + if (document.languageId !== 'dart') { + return; + } + + const projectRoot = this.resolveProjectRoot(document); + if (!projectRoot) { + throw new Error('Unable to find a Dart/Flutter project root (pubspec.yaml) for preview.'); + } + + const screens = discoverScreens(document); + if (screens.length === 0) { + throw new Error('No @StacScreen declarations found in this document.'); + } + + const preferred = this.preferredScreenByDocument.get(document.uri.fsPath); + const screen = chooseScreenDescriptor(screens, cursorOffset, preferred); + if (!screen) { + throw new Error('Unable to resolve a screen for preview.'); + } + + this.preferredScreenByDocument.set(document.uri.fsPath, screen.screenName); + this.activeDocumentUri = document.uri; + this.outputChannel.appendLine( + `[preview] Rendering screen ${screen.screenName} from ${document.uri.fsPath}`, + ); + + if (!this.assetServer) { + this.assetServer = new AssetServer((msg) => this.outputChannel.appendLine(msg)); + } else if (this.lastAssetRoot && this.lastAssetRoot !== projectRoot) { + this.outputChannel.appendLine(`[preview] Project root changed, restarting asset server...`); + this.assetServer.stop(); + this.assetServer = new AssetServer((msg) => this.outputChannel.appendLine(msg)); + } + this.lastAssetRoot = projectRoot; + + // Ensure package resolution is set up before running any dart scripts + await this.ensurePackageResolution(projectRoot); + + // Start content generation immediately — JSON generation, theme resolution, + // font discovery, and asset server are all independent and can run in parallel + // with each other AND with the Flutter host compilation. + const contentPromise = Promise.all([ + this.assetServer.start(projectRoot), + generatePreviewJson({ + workspaceRoot: projectRoot, + sourceFilePath: document.uri.fsPath, + screenName: screen.screenName, + functionName: screen.functionName, + runnerSupported: screen.runnerSupported, + strategy: settings.strategy, + buildCommand: expandBuildCommandTokens(settings.buildCommand, { + workspaceFolder: this.resolveWorkspaceFolderPath(document), + projectFolder: projectRoot, + }), + outputDirCandidates: settings.outputDirCandidates, + outputChannel: this.outputChannel, + }), + findFontsInPubspec(projectRoot).catch(() => []), + this.selectedThemeName + ? this.resolveThemeJson(projectRoot).catch((e) => { + this.outputChannel.appendLine(`[preview] Theme resolution failed: ${String(e)}`); + return undefined; + }) + : Promise.resolve(undefined), + ]); + + // Wait for host and panel — on first open this blocks while the Flutter web + // server compiles, but content generation is running in parallel above. + // On subsequent refreshes this returns instantly. + await this.ensurePanelAndHost(settings); + if (!this.panel) { + throw new Error('Preview panel is not available.'); + } + + void this.panel.postState('building', `Building preview for ${screen.screenName}...`); + + // Await content results (likely already done if host startup was the bottleneck) + const [assetServerPort, result, fonts, themeJson] = await contentPromise; + + const transformedJson = transformJson(result.json, assetServerPort); + + try { + if (fonts.length > 0) { + const fontsPayload = fonts.map(f => ({ + family: f.family, + urls: f.fonts.map(asset => + `http://127.0.0.1:${assetServerPort}/${asset.asset.replace(/^\//, '')}` + ) + })); + + this.panel?.postMessage({ + type: 'stac.preview.loadFonts', + fonts: fontsPayload + }); + this.outputChannel.appendLine( + `[preview] Sending ${fonts.length} font families to host.` + ); + } else { + this.outputChannel.appendLine('[preview] No fonts found in pubspec.yaml.'); + } + } catch (e) { + this.outputChannel.appendLine(`[preview] Failed to load fonts: ${e}`); + } + + const payload: PreviewRenderMessage = { + type: 'stac.preview.render', + screenName: screen.screenName, + json: transformedJson, + sourcePath: result.jsonPath, + timestamp: new Date().toISOString(), + requestId: createRenderRequestId(), + }; + + if (themeJson) { + payload.theme = themeJson; + } + + this.lastRequestedScreenName = screen.screenName; + this.lastRenderRequestId = payload.requestId; + this.lastRenderMessage = payload; + + if (!this.panel) { + return; + } + + await this.panel.postRender(payload); + this.panel?.postState( + 'ready', + `Preview payload sent for ${screen.screenName} via ${result.source}.`, + ); + } + + private createOrUpdatePanel(hostUrl: string, hostPort: number): void { + if (!this.panel || this.panelHostPort !== hostPort) { + if (this.panel) { + this.panel.dispose(); + } + this.panel = new PreviewPanel(this.context.extensionUri, hostUrl, hostPort); + this.panelHostPort = hostPort; + this.panel.onDidDispose(() => { + this.panel = undefined; + this.panelHostPort = undefined; + }); + this.panel.onDidReceiveMessage((message) => { + void this.handleWebviewMessage(message); + }); + // When the user clicks the preview panel, VS Code makes it the "active" + // editor group. Any subsequent file-open from the explorer would then + // create a new split column instead of opening in the editor column. + // To prevent this, shift focus back to the last active text editor after + // a short delay (enough for webview click events to fire). + this.panel.onDidChangeViewState((e) => { + if (e.webviewPanel.active) { + setTimeout(() => { + const editor = vscode.window.activeTextEditor; + if (editor) { + void vscode.window.showTextDocument( + editor.document, + editor.viewColumn, + false, + ); + } + }, 200); + } + }); + } else { + this.panel.updateHostUrl(hostUrl); + this.panel.reveal(); + } + } + + private async ensurePanelAndHost(settings: PreviewSettings): Promise { + const host = await this.getOrCreateHostProcess(settings); + const hostUrl = await host.ensureStarted(); + this.createOrUpdatePanel(hostUrl, host.hostPort); + } + + private async handleWebviewMessage(message: PreviewWebviewMessage): Promise { + if (message.type === 'stac.preview.retry') { + await this.refreshPreview(); + return; + } + + if (message.type === 'stac.preview.webview.ready') { + if (this.panel && this.lastRenderMessage) { + void this.panel.postRender(this.lastRenderMessage); + } + if (this.panel && this.discoveredThemes.length > 0) { + void this.panel.postThemes( + this.discoveredThemes.map((t) => ({ themeName: t.themeName })), + this.selectedThemeName ?? null, + ); + } + return; + } + + if (message.type === 'stac.preview.selectTheme') { + this.selectedThemeName = message.themeName ?? undefined; + this.outputChannel.appendLine( + `[preview] Theme selected: ${this.selectedThemeName ?? 'none'}`, + ); + // Fast-path: reuse last screen JSON, only re-resolve theme + await this.reRenderWithCurrentTheme(); + return; + } + + this.handlePreviewHostEvent(message); + } + + private handlePreviewHostEvent(message: PreviewOutboundMessage) { + if (message.type === 'stac.preview.ready') { + this.outputChannel.appendLine(`[preview] Host ready: ${message.message ?? ''}`); + if (this.panel) { + void this.panel.postState('ready', message.message ?? 'Preview host ready.'); + if (this.lastRenderMessage) { + void this.panel.postRender(this.lastRenderMessage); + } + } + return; + } + + if (message.type === 'stac.preview.rendered') { + const requestId = message.requestId; + if ( + requestId + && this.lastRenderRequestId + && requestId !== this.lastRenderRequestId + ) { + this.outputChannel.appendLine( + `[preview] Ignoring stale rendered ack for request ${requestId}.`, + ); + return; + } + + if (message.message) { + this.outputChannel.appendLine(`[preview] ${message.message}`); + } + if (this.panel) { + const screenName = message.screenName ?? this.lastRequestedScreenName ?? 'screen'; + void this.panel.postState('rendered', `Rendered ${screenName}.`); + } + return; + } + + if (message.type === 'stac.preview.error') { + if ( + message.requestId + && this.lastRenderRequestId + && message.requestId !== this.lastRenderRequestId + ) { + return; + } + const detail = message.message ?? 'Preview host reported an error.'; + this.outputChannel.appendLine(`[preview] ${detail}`); + if (this.panel) { + void this.panel.postState('error', detail); + } + return; + } + + if (message.type === 'stac.preview.log') { + const detail = message.message; + this.outputChannel.appendLine(`[preview] [host] ${detail}`); + } + } + + private async getOrCreateHostProcess(settings: PreviewSettings): Promise { + const key = `${settings.hostPort}:${settings.startupTimeoutMs}`; + if (!this.hostProcess || this.hostSettingsKey !== key) { + if (this.hostProcess) { + await this.hostProcess.stop(); + } + + this.hostProcess = new PreviewHostProcess({ + extensionPath: this.context.extensionPath, + outputChannel: this.outputChannel, + port: settings.hostPort, + startupTimeoutMs: settings.startupTimeoutMs, + }); + this.hostSettingsKey = key; + } + + return this.hostProcess; + } + + private warmUpHost(settings: PreviewSettings): void { + void this.getOrCreateHostProcess(settings).then( + (host) => host.ensureStarted(), + () => { /* Startup errors are handled when ensurePanelAndHost is awaited */ }, + ); + } + + private resolveWorkspaceFolderPath(document: vscode.TextDocument): string | undefined { + const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri); + if (workspaceFolder) { + return workspaceFolder.uri.fsPath; + } + + const first = vscode.workspace.workspaceFolders?.at(0); + return first?.uri.fsPath; + } + + private resolveProjectRoot(document: vscode.TextDocument): string | undefined { + const workspaceFolder = this.resolveWorkspaceFolderPath(document); + const documentPath = document.uri.fsPath; + let current = path.dirname(documentPath); + + while (true) { + const pubspecPath = path.join(current, 'pubspec.yaml'); + if (existsSync(pubspecPath)) { + return current; + } + + const parent = path.dirname(current); + if (parent === current) { + return workspaceFolder; + } + + if (workspaceFolder && !isWithinPath(parent, workspaceFolder)) { + return workspaceFolder; + } + + current = parent; + } + } + + /** + * Re-render using the last screen JSON but with fresh theme resolution. + * Avoids expensive screen JSON regeneration when only the theme changes. + */ + private async reRenderWithCurrentTheme(): Promise { + if (!this.panel || !this.lastRenderMessage) { + // No previous render; fall back to full refresh + if (this.activeDocumentUri) { + this.enqueueRefresh(this.activeDocumentUri); + } + return; + } + + const projectRoot = this.activeDocumentUri + ? this.resolveProjectRoot( + await vscode.workspace.openTextDocument(this.activeDocumentUri), + ) + : undefined; + + const payload: PreviewRenderMessage = { + ...this.lastRenderMessage, + theme: undefined, + requestId: createRenderRequestId(), + timestamp: new Date().toISOString(), + }; + + if (this.selectedThemeName && projectRoot) { + const themeJson = await this.resolveThemeJson(projectRoot); + if (themeJson) { + payload.theme = themeJson; + } + } + + this.lastRenderRequestId = payload.requestId; + this.lastRenderMessage = payload; + + if (!this.panel) { + return; + } + + await this.panel.postRender(payload); + } + + private async refreshThemeList(): Promise { + const workspaceFolder = vscode.workspace.workspaceFolders?.at(0)?.uri.fsPath; + if (!workspaceFolder) { + return; + } + + try { + this.discoveredThemes = await discoverThemesInWorkspace(workspaceFolder); + this.outputChannel.appendLine( + `[preview] Discovered ${this.discoveredThemes.length} theme(s): ${this.discoveredThemes.map((t) => t.themeName).join(', ') || 'none'}`, + ); + + if (this.discoveredThemes.length > 0 && !this.selectedThemeName) { + this.selectedThemeName = this.discoveredThemes[0].themeName; + this.outputChannel.appendLine(`[preview] Auto-selected theme: ${this.selectedThemeName}`); + } + + if (this.panel) { + void this.panel.postThemes( + this.discoveredThemes.map((t) => ({ themeName: t.themeName })), + this.selectedThemeName ?? null, + ); + } + } catch (error) { + this.outputChannel.appendLine(`[preview] Theme discovery failed: ${String(error)}`); + } + } + + private async resolveThemeJson(projectRoot: string): Promise | undefined> { + if (!this.selectedThemeName) { + return undefined; + } + + // Check cache first + const cached = this.themeJsonCache.get(this.selectedThemeName); + if (cached) { + return cached; + } + + const theme = this.discoveredThemes.find( + (t) => t.themeName === this.selectedThemeName, + ); + if (!theme || !theme.topLevel) { + this.outputChannel.appendLine( + `[preview] Theme "${this.selectedThemeName}" not found or not top-level.`, + ); + return undefined; + } + + try { + const artifacts = await writeThemeRunnerArtifacts( + projectRoot, + theme.filePath, + theme.functionOrGetterName, + theme.themeName, + theme.isGetter, + ); + + this.outputChannel.appendLine( + `[preview] Running theme runner: ${artifacts.scriptPath}`, + ); + + // Use relative path from project root so dart run can resolve packages correctly + const relativeScriptPath = path.relative(projectRoot, artifacts.scriptPath); + const result = await this.runDartCommand( + ['run', relativeScriptPath, artifacts.outputPath], + projectRoot, + ); + + if (result.exitCode !== 0) { + this.outputChannel.appendLine( + `[preview] Theme runner failed (exit ${result.exitCode}).`, + ); + return undefined; + } + + const json = await readJsonFile(artifacts.outputPath); + this.themeJsonCache.set(this.selectedThemeName, json); + return json; + } catch (error) { + this.outputChannel.appendLine( + `[preview] Failed to generate theme JSON: ${String(error)}`, + ); + return undefined; + } + } + + private async ensurePackageResolution(projectRoot: string): Promise { + if (this.resolvedProjectRoots.has(projectRoot)) { + return; + } + + const pubspecPath = path.join(projectRoot, 'pubspec.yaml'); + if (!existsSync(pubspecPath)) { + return; + } + + const packageConfigPath = path.join(projectRoot, '.dart_tool', 'package_config.json'); + let needsResolution = !existsSync(packageConfigPath); + + if (!needsResolution) { + try { + const pubspecMtime = statSync(pubspecPath).mtimeMs; + const configMtime = statSync(packageConfigPath).mtimeMs; + needsResolution = pubspecMtime > configMtime; + } catch { + needsResolution = true; + } + } + + if (!needsResolution) { + this.resolvedProjectRoots.add(projectRoot); + return; + } + + try { + const pubspecContent = await vscode.workspace.fs.readFile( + vscode.Uri.file(pubspecPath), + ); + const pubspecText = Buffer.from(pubspecContent).toString('utf8'); + const isFlutterProject = /\bflutter\s*:/.test(pubspecText); + + const command = isFlutterProject ? 'flutter' : 'dart'; + this.outputChannel.appendLine( + `[preview] Running ${command} pub get to resolve packages...`, + ); + + const result = await this.runDartCommand( + ['pub', 'get'], + projectRoot, + 120_000, + command, + ); + + if (result.exitCode === 0) { + this.resolvedProjectRoots.add(projectRoot); + } else { + this.outputChannel.appendLine( + `[preview] Warning: ${command} pub get failed (exit ${result.exitCode}). Package resolution may fail.`, + ); + } + } catch (error) { + this.outputChannel.appendLine( + `[preview] Warning: Failed to ensure package resolution: ${String(error)}`, + ); + } + } + + private runDartCommand( + args: readonly string[], + cwd: string, + timeoutMs = 30_000, + command = 'dart', + ): Promise<{ exitCode: number }> { + return new Promise((resolve, reject) => { + const child = spawn(command, [...args], { + cwd, + env: process.env, + shell: process.platform === 'win32', + }); + let settled = false; + + const timer = setTimeout(() => { + if (!settled) { + settled = true; + child.kill(); + this.outputChannel.appendLine( + `[preview] dart command timed out after ${timeoutMs}ms`, + ); + resolve({ exitCode: 124 }); + } + }, timeoutMs); + + child.stdout.on('data', (chunk: Buffer | string) => { + this.outputChannel.append(chunk.toString()); + }); + + child.stderr.on('data', (chunk: Buffer | string) => { + this.outputChannel.append(chunk.toString()); + }); + + child.on('error', (error) => { + if (!settled) { + settled = true; + clearTimeout(timer); + reject(error); + } + }); + + child.on('close', (code) => { + if (!settled) { + settled = true; + clearTimeout(timer); + resolve({ exitCode: code ?? 1 }); + } + }); + }); + } + + private getSettings(): PreviewSettings { + const config = vscode.workspace.getConfiguration(); + const strategyValue = config.get(SETTINGS.previewJsonStrategy, 'runnerThenBuild'); + const strategy = isPreviewJsonStrategy(strategyValue) ? strategyValue : 'runnerThenBuild'; + + return { + enabled: config.get(SETTINGS.previewEnable, true), + autoRefreshOnSave: config.get(SETTINGS.previewAutoRefreshOnSave, true), + strategy, + buildCommand: config.get( + SETTINGS.previewBuildCommand, + 'stac build --project "${projectFolder}"', + ), + outputDirCandidates: config.get( + SETTINGS.previewOutputDirCandidates, + ['stac/.build', 'build/screens', 'build'], + ), + hostPort: config.get(SETTINGS.previewHostPort, 47841), + startupTimeoutMs: config.get(SETTINGS.previewStartupTimeoutMs, 120000), + }; + } +} + +function isPreviewJsonStrategy(value: string): value is PreviewJsonStrategy { + return value === 'runnerThenBuild' || value === 'runnerOnly' || value === 'buildOnly'; +} + +function expandBuildCommandTokens( + input: string, + values: { workspaceFolder?: string; projectFolder: string }, +): string { + const workspaceFolder = values.workspaceFolder ?? values.projectFolder; + return input + .replaceAll('${workspaceFolder}', workspaceFolder) + .replaceAll('${projectFolder}', values.projectFolder); +} + +function isWithinPath(candidate: string, parent: string): boolean { + const relative = path.relative(parent, candidate); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +function createRenderRequestId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +} diff --git a/tools/stac-vscode/src/preview/previewPanel.ts b/tools/stac-vscode/src/preview/previewPanel.ts new file mode 100644 index 000000000..f57698b92 --- /dev/null +++ b/tools/stac-vscode/src/preview/previewPanel.ts @@ -0,0 +1,703 @@ +import * as vscode from 'vscode'; +import type { + PreviewRenderMessage, + PreviewState, + PreviewThemesMessage, + PreviewWebviewMessage, +} from './types'; + +export class PreviewPanel implements vscode.Disposable { + private panel: vscode.WebviewPanel; + + private readonly disposables: vscode.Disposable[] = []; + + private hostUrl: string; + + constructor( + extensionUri: vscode.Uri, + hostUrl: string, + hostPort: number, + ) { + this.hostUrl = hostUrl; + this.panel = vscode.window.createWebviewPanel( + 'stacPreview', + 'Stac Preview', + { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true }, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [extensionUri], + portMapping: [ + { + webviewPort: hostPort, + extensionHostPort: hostPort, + }, + ], + }, + ); + this.panel.webview.html = getWebviewHtml(this.panel.webview, this.hostUrl); + this.panel.iconPath = { + light: vscode.Uri.joinPath(extensionUri, 'media', 'preview-icon-light.svg'), + dark: vscode.Uri.joinPath(extensionUri, 'media', 'preview-icon.svg'), + }; + this.disposables.push(this.panel); + } + + updateHostUrl(hostUrl: string) { + if (this.hostUrl === hostUrl) { + return; + } + + this.hostUrl = hostUrl; + this.panel.webview.html = getWebviewHtml(this.panel.webview, this.hostUrl); + } + + isVisible(): boolean { + return this.panel.visible; + } + + reveal() { + this.panel.reveal(vscode.ViewColumn.Beside, true); + } + + onDidDispose(listener: () => void): vscode.Disposable { + return this.panel.onDidDispose(listener, undefined, this.disposables); + } + + onDidReceiveMessage( + listener: (message: PreviewWebviewMessage) => void, + ): vscode.Disposable { + return this.panel.webview.onDidReceiveMessage( + (message: PreviewWebviewMessage) => listener(message), + undefined, + this.disposables, + ); + } + + onDidChangeViewState( + listener: (e: vscode.WebviewPanelOnDidChangeViewStateEvent) => void, + ): vscode.Disposable { + return this.panel.onDidChangeViewState(listener, undefined, this.disposables); + } + + postRender(message: PreviewRenderMessage): Thenable { + return this.panel.webview.postMessage(message); + } + + postState(state: PreviewState, detail: string): Thenable { + return this.panel.webview.postMessage({ + type: 'stac.preview.state', + state, + message: detail, + }); + } + + postThemes( + themes: Array<{ themeName: string }>, + selectedThemeName: string | null, + ): Thenable { + return this.panel.webview.postMessage({ + type: 'stac.preview.themes', + themes, + selectedThemeName, + } as PreviewThemesMessage & { selectedThemeName: string | null }); + } + + postMessage(message: unknown): Thenable { + return this.panel.webview.postMessage(message); + } + + dispose() { + while (this.disposables.length > 0) { + const item = this.disposables.pop(); + item?.dispose(); + } + } +} + +function getWebviewHtml(webview: vscode.Webview, hostUrl: string): string { + const csp = webview.cspSource; + const escapedHostUrl = escapeHtml(hostUrl); + + return ` + + + + + + Stac Preview + + + +
+
+ + Starting preview host... +
+
+ + + +
+ + +
+
+
+
+
+
+ +
+
+ + 100% + +
+
+ + + +`; +} + +function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} diff --git a/tools/stac-vscode/src/preview/runnerScript.ts b/tools/stac-vscode/src/preview/runnerScript.ts new file mode 100644 index 000000000..289138372 --- /dev/null +++ b/tools/stac-vscode/src/preview/runnerScript.ts @@ -0,0 +1,141 @@ +import { promises as fs } from 'node:fs'; +import * as path from 'node:path'; +import { createHash } from 'node:crypto'; +import { pathToFileURL } from 'node:url'; + +export interface RunnerArtifacts { + scriptPath: string; + outputPath: string; +} + +export function buildRunnerScript( + sourceFilePath: string, + functionName: string, +): string { + const importUri = pathToFileURL(sourceFilePath).href; + + return [ + "import 'dart:convert';", + "import 'dart:io';", + `import '${importUri}' as target;`, + '', + 'Future main(List args) async {', + ' if (args.isEmpty) {', + " stderr.writeln('Missing output file path argument.');", + ' exit(64);', + ' }', + '', + ' final outputPath = args.first;', + ' try {', + ` final data = target.${functionName}().toJson();`, + " final encoder = JsonEncoder.withIndent(' ');", + ' final file = File(outputPath);', + ' await file.parent.create(recursive: true);', + " await file.writeAsString(encoder.convert(data) + '\\n');", + ' } catch (error, stackTrace) {', + " stderr.writeln('Failed to render preview JSON: $error');", + " stderr.writeln('$stackTrace');", + ' exit(1);', + ' }', + '}', + '', + ].join('\n'); +} + +export async function writeRunnerArtifacts( + workspaceRoot: string, + sourceFilePath: string, + functionName: string, + screenName: string, +): Promise { + const hash = createHash('sha1') + .update(sourceFilePath) + .update(functionName) + .digest('hex') + .slice(0, 12); + const safeScreenName = sanitizePathSegment(screenName); + const artifactsDir = path.join(workspaceRoot, '.dart_tool', 'stac_vscode'); + const scriptPath = path.join(artifactsDir, `preview_runner_${hash}.dart`); + const outputPath = path.join(artifactsDir, `preview_${safeScreenName}_${hash}.json`); + + await fs.mkdir(artifactsDir, { recursive: true }); + await fs.writeFile(scriptPath, buildRunnerScript(sourceFilePath, functionName), 'utf8'); + + return { + scriptPath, + outputPath, + }; +} + +function sanitizePathSegment(value: string): string { + const normalized = value.replace(/[^a-zA-Z0-9_-]/g, '_'); + return normalized.length > 0 ? normalized : 'screen'; +} + +export function buildThemeRunnerScript( + sourceFilePath: string, + functionOrGetterName: string, + isGetter: boolean, +): string { + const importUri = pathToFileURL(sourceFilePath).href; + const invocation = isGetter + ? `target.${functionOrGetterName}.toJson()` + : `target.${functionOrGetterName}().toJson()`; + + return [ + "import 'dart:convert';", + "import 'dart:io';", + `import '${importUri}' as target;`, + '', + 'Future main(List args) async {', + ' if (args.isEmpty) {', + " stderr.writeln('Missing output file path argument.');", + ' exit(64);', + ' }', + '', + ' final outputPath = args.first;', + ' try {', + ` final data = ${invocation};`, + " final encoder = JsonEncoder.withIndent(' ');", + ' final file = File(outputPath);', + ' await file.parent.create(recursive: true);', + " await file.writeAsString(encoder.convert(data) + '\\n');", + ' } catch (error, stackTrace) {', + " stderr.writeln('Failed to render theme JSON: $error');", + " stderr.writeln('$stackTrace');", + ' exit(1);', + ' }', + '}', + '', + ].join('\n'); +} + +export async function writeThemeRunnerArtifacts( + workspaceRoot: string, + sourceFilePath: string, + functionOrGetterName: string, + themeName: string, + isGetter: boolean, +): Promise { + const hash = createHash('sha1') + .update(sourceFilePath) + .update(functionOrGetterName) + .digest('hex') + .slice(0, 12); + const safeThemeName = sanitizePathSegment(themeName); + const artifactsDir = path.join(workspaceRoot, '.dart_tool', 'stac_vscode'); + const scriptPath = path.join(artifactsDir, `theme_runner_${hash}.dart`); + const outputPath = path.join(artifactsDir, `theme_${safeThemeName}_${hash}.json`); + + await fs.mkdir(artifactsDir, { recursive: true }); + await fs.writeFile( + scriptPath, + buildThemeRunnerScript(sourceFilePath, functionOrGetterName, isGetter), + 'utf8', + ); + + return { + scriptPath, + outputPath, + }; +} diff --git a/tools/stac-vscode/src/preview/screenDiscovery.ts b/tools/stac-vscode/src/preview/screenDiscovery.ts new file mode 100644 index 000000000..f9d5b0022 --- /dev/null +++ b/tools/stac-vscode/src/preview/screenDiscovery.ts @@ -0,0 +1,263 @@ +import * as vscode from 'vscode'; +import type { ScreenDescriptor } from './types'; + +const SCREEN_DECLARATION_REGEX = + /@StacScreen\s*\(\s*screenName\s*:\s*(['"])([^'"]+)\1[\s\S]*?\)\s*StacWidget\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)/g; + +export function discoverScreens(document: vscode.TextDocument): ScreenDescriptor[] { + const text = document.getText(); + const screens: ScreenDescriptor[] = []; + + for (const match of text.matchAll(SCREEN_DECLARATION_REGEX)) { + const fullMatch = match[0]; + const annotationOffset = match.index; + const screenName = match[2]; + const functionName = match[3]; + const parameters = (match[4] ?? '').trim(); + + if ( + fullMatch === undefined || + annotationOffset === undefined || + screenName === undefined || + functionName === undefined + ) { + continue; + } + + const functionSignatureIndex = fullMatch.indexOf(`StacWidget ${functionName}`); + const functionOffset = functionSignatureIndex >= 0 + ? annotationOffset + functionSignatureIndex + : annotationOffset; + const openBraceOffset = text.indexOf('{', functionOffset); + const functionEndOffset = openBraceOffset >= 0 + ? findMatchingBraceOffset(text, openBraceOffset) ?? openBraceOffset + : functionOffset; + + const hasParameters = parameters.length > 0; + const topLevel = computeBraceDepthAt(text, annotationOffset) === 0; + + screens.push({ + screenName, + functionName, + annotationOffset, + functionOffset, + functionEndOffset, + hasParameters, + topLevel, + runnerSupported: topLevel && !hasParameters, + }); + } + + return screens; +} + +export function chooseScreenDescriptor( + screens: readonly ScreenDescriptor[], + cursorOffset?: number, + preferredScreenName?: string, +): ScreenDescriptor | undefined { + if (screens.length === 0) { + return undefined; + } + + if (cursorOffset !== undefined) { + for (const screen of screens) { + if (cursorOffset >= screen.annotationOffset && cursorOffset <= screen.functionEndOffset) { + return screen; + } + } + } + + if (preferredScreenName) { + const preferred = screens.find((screen) => screen.screenName === preferredScreenName); + if (preferred) { + return preferred; + } + } + + return screens[0]; +} + +export async function pickScreenDescriptor( + screens: readonly ScreenDescriptor[], +): Promise { + if (screens.length === 0) { + return undefined; + } + + const selected = await vscode.window.showQuickPick( + screens.map((screen) => ({ + label: screen.screenName, + description: screen.functionName, + detail: screen.runnerSupported + ? 'Runner supported' + : 'Runner unsupported, build fallback only', + screen, + })), + { + title: 'Select Stac screen for preview', + placeHolder: 'Pick a @StacScreen target', + }, + ); + + return selected?.screen; +} + +function computeBraceDepthAt(text: string, targetOffset: number): number { + type State = 'normal' | 'single' | 'double' | 'lineComment' | 'blockComment'; + let state: State = 'normal'; + let depth = 0; + let escaped = false; + + for (let index = 0; index < targetOffset; index += 1) { + const char = text[index]; + const next = text[index + 1]; + + if (state === 'lineComment') { + if (char === '\n') { + state = 'normal'; + } + continue; + } + + if (state === 'blockComment') { + if (char === '*' && next === '/') { + state = 'normal'; + index += 1; + } + continue; + } + + if (state === 'single') { + if (!escaped && char === "'") { + state = 'normal'; + } + escaped = !escaped && char === '\\'; + continue; + } + + if (state === 'double') { + if (!escaped && char === '"') { + state = 'normal'; + } + escaped = !escaped && char === '\\'; + continue; + } + + if (char === '/' && next === '/') { + state = 'lineComment'; + index += 1; + continue; + } + + if (char === '/' && next === '*') { + state = 'blockComment'; + index += 1; + continue; + } + + if (char === "'") { + state = 'single'; + escaped = false; + continue; + } + + if (char === '"') { + state = 'double'; + escaped = false; + continue; + } + + if (char === '{') { + depth += 1; + continue; + } + + if (char === '}') { + depth = Math.max(0, depth - 1); + } + } + + return depth; +} + +function findMatchingBraceOffset(text: string, openBraceOffset: number): number | undefined { + type State = 'normal' | 'single' | 'double' | 'lineComment' | 'blockComment'; + let state: State = 'normal'; + let escaped = false; + let depth = 0; + + for (let index = openBraceOffset; index < text.length; index += 1) { + const char = text[index]; + const next = text[index + 1]; + + if (state === 'lineComment') { + if (char === '\n') { + state = 'normal'; + } + continue; + } + + if (state === 'blockComment') { + if (char === '*' && next === '/') { + state = 'normal'; + index += 1; + } + continue; + } + + if (state === 'single') { + if (!escaped && char === "'") { + state = 'normal'; + } + escaped = !escaped && char === '\\'; + continue; + } + + if (state === 'double') { + if (!escaped && char === '"') { + state = 'normal'; + } + escaped = !escaped && char === '\\'; + continue; + } + + if (char === '/' && next === '/') { + state = 'lineComment'; + index += 1; + continue; + } + + if (char === '/' && next === '*') { + state = 'blockComment'; + index += 1; + continue; + } + + if (char === "'") { + state = 'single'; + escaped = false; + continue; + } + + if (char === '"') { + state = 'double'; + escaped = false; + continue; + } + + if (char === '{') { + depth += 1; + continue; + } + + if (char === '}') { + depth -= 1; + if (depth === 0) { + return index; + } + } + } + + return undefined; +} diff --git a/tools/stac-vscode/src/preview/themeDiscovery.ts b/tools/stac-vscode/src/preview/themeDiscovery.ts new file mode 100644 index 000000000..fb352b130 --- /dev/null +++ b/tools/stac-vscode/src/preview/themeDiscovery.ts @@ -0,0 +1,123 @@ +import * as vscode from 'vscode'; +import type { ThemeDescriptor } from './types'; + +/** + * Regex to match `@StacThemeRef(name: "themeName")` annotations followed by + * either a getter (`StacTheme get name => ...`) or a function (`StacTheme name() { ... }`). + * + * Captures: + * [1] quote character (' or ") + * [2] theme name + * [3] 'get' keyword (present for getters, undefined for functions) + * [4] function/getter name + */ +const THEME_DECLARATION_REGEX = + /@StacThemeRef\s*\(\s*name\s*:\s*(['"])([^'"]+)\1[\s\S]*?\)\s*StacTheme\s+(get\s+)?([A-Za-z_][A-Za-z0-9_]*)/g; + +/** + * Discover all `@StacThemeRef` declarations in the given document. + */ +export function discoverThemesInDocument(document: vscode.TextDocument): ThemeDescriptor[] { + const text = document.getText(); + const themes: ThemeDescriptor[] = []; + + for (const match of text.matchAll(THEME_DECLARATION_REGEX)) { + const themeName = match[2]; + const isGetter = match[3] !== undefined; + const functionOrGetterName = match[4]; + const annotationOffset = match.index; + + if ( + themeName === undefined || + functionOrGetterName === undefined || + annotationOffset === undefined + ) { + continue; + } + + const topLevel = computeBraceDepthAt(text, annotationOffset) === 0; + + themes.push({ + themeName, + filePath: document.uri.fsPath, + functionOrGetterName, + isGetter, + topLevel, + }); + } + + return themes; +} + +/** + * Discover all `@StacThemeRef` declarations across the workspace. + * Scans all `.dart` files in the given workspace root. + */ +export async function discoverThemesInWorkspace( + workspaceRoot: string, +): Promise { + const pattern = new vscode.RelativePattern(workspaceRoot, '**/*.dart'); + const excludes = '{**/.*/**,.dart_tool/**,build/**,**/build/**}'; + const files = await vscode.workspace.findFiles(pattern, excludes, 500); + + const allThemes: ThemeDescriptor[] = []; + + for (const fileUri of files) { + try { + const document = await vscode.workspace.openTextDocument(fileUri); + const themes = discoverThemesInDocument(document); + allThemes.push(...themes); + } catch { + // Skip files that can't be opened + } + } + + return allThemes; +} + +/** + * Compute the brace depth at a given offset to determine if a declaration is top-level. + * Skips strings and comments. + */ +function computeBraceDepthAt(text: string, targetOffset: number): number { + type State = 'normal' | 'single' | 'double' | 'lineComment' | 'blockComment'; + let state: State = 'normal'; + let depth = 0; + let escaped = false; + + for (let index = 0; index < targetOffset; index += 1) { + const char = text[index]; + const next = text[index + 1]; + + if (state === 'lineComment') { + if (char === '\n') { state = 'normal'; } + continue; + } + + if (state === 'blockComment') { + if (char === '*' && next === '/') { state = 'normal'; index += 1; } + continue; + } + + if (state === 'single') { + if (!escaped && char === "'") { state = 'normal'; } + escaped = !escaped && char === '\\'; + continue; + } + + if (state === 'double') { + if (!escaped && char === '"') { state = 'normal'; } + escaped = !escaped && char === '\\'; + continue; + } + + if (char === '/' && next === '/') { state = 'lineComment'; index += 1; continue; } + if (char === '/' && next === '*') { state = 'blockComment'; index += 1; continue; } + if (char === "'") { state = 'single'; escaped = false; continue; } + if (char === '"') { state = 'double'; escaped = false; continue; } + if (char === '{') { depth += 1; continue; } + if (char === '}') { depth = Math.max(0, depth - 1); } + } + + return depth; +} diff --git a/tools/stac-vscode/src/preview/types.ts b/tools/stac-vscode/src/preview/types.ts new file mode 100644 index 000000000..db0834925 --- /dev/null +++ b/tools/stac-vscode/src/preview/types.ts @@ -0,0 +1,98 @@ +export type PreviewJsonStrategy = 'runnerThenBuild' | 'runnerOnly' | 'buildOnly'; + +export type PreviewState = 'starting' | 'building' | 'ready' | 'rendered' | 'error'; + +export interface PreviewRenderMessage { + type: 'stac.preview.render'; + screenName: string; + json: Record; + theme?: Record; + sourcePath: string; + timestamp: string; + requestId: string; +} + +export interface PreviewPanelStateMessage { + type: 'stac.preview.state'; + state: PreviewState; + message: string; +} + +export interface PreviewRetryMessage { + type: 'stac.preview.retry'; +} + +export interface PreviewReadyEvent { + type: 'stac.preview.ready'; + message?: string; + requestId?: string; +} + +export interface PreviewRenderedEvent { + type: 'stac.preview.rendered'; + message?: string; + requestId?: string; + screenName?: string; +} + +export interface PreviewErrorEvent { + type: 'stac.preview.error'; + message?: string; + requestId?: string; +} + +export interface PreviewLogEvent { + type: 'stac.preview.log'; + message: string; +} + +export interface PreviewWebviewReadyEvent { + type: 'stac.preview.webview.ready'; +} + +export interface PreviewThemesMessage { + type: 'stac.preview.themes'; + themes: Array<{ themeName: string }>; +} + +export interface PreviewSelectThemeMessage { + type: 'stac.preview.selectTheme'; + themeName: string | null; +} + +export type PreviewOutboundMessage = + | PreviewReadyEvent + | PreviewRenderedEvent + | PreviewErrorEvent + | PreviewLogEvent; + +export type PreviewWebviewMessage = + | PreviewOutboundMessage + | PreviewRetryMessage + | PreviewWebviewReadyEvent + | PreviewSelectThemeMessage; + +export interface ScreenDescriptor { + screenName: string; + functionName: string; + annotationOffset: number; + functionOffset: number; + functionEndOffset: number; + hasParameters: boolean; + topLevel: boolean; + runnerSupported: boolean; +} + +export interface JsonGenerationResult { + source: 'runner' | 'build'; + json: Record; + jsonPath: string; +} + +export interface ThemeDescriptor { + themeName: string; + filePath: string; + functionOrGetterName: string; + isGetter: boolean; + topLevel: boolean; +} diff --git a/tools/stac-vscode/src/preview/utils.ts b/tools/stac-vscode/src/preview/utils.ts new file mode 100644 index 000000000..f6b62b628 --- /dev/null +++ b/tools/stac-vscode/src/preview/utils.ts @@ -0,0 +1,26 @@ +import * as net from 'node:net'; + +export async function findAvailablePort(startPort: number, maxChecks: number): Promise { + for (let port = startPort; port < startPort + maxChecks; port += 1) { + const available = await canBindPort(port); + if (available) { + return port; + } + } + + return undefined; +} + +export function canBindPort(port: number): Promise { + return new Promise((resolve) => { + const server = net.createServer(); + server.once('error', () => { + resolve(false); + }); + server.listen(port, '127.0.0.1', () => { + server.close(() => { + resolve(true); + }); + }); + }); +} diff --git a/tools/stac-vscode/src/snippets/stacSnippetCompletionProvider.ts b/tools/stac-vscode/src/snippets/stacSnippetCompletionProvider.ts new file mode 100644 index 000000000..14fec1d44 --- /dev/null +++ b/tools/stac-vscode/src/snippets/stacSnippetCompletionProvider.ts @@ -0,0 +1,97 @@ +import * as vscode from 'vscode'; +import { SETTINGS } from '../core/constants'; +import { isStacDslDocument } from '../core/isStacDslDocument'; + +interface SimpleSnippet { + prefix: string; + description: string; + body: string[]; +} + +const SIMPLE_SNIPPETS: readonly SimpleSnippet[] = [ + { + prefix: 'stac screen', + description: 'Create a new Stac screen', + body: [ + "import 'package:stac_core/stac_core.dart';", + '', + '@StacScreen(screenName: "${1/(^[A-Z])|([A-Z])/${1:/downcase}${2:+_}${2:/downcase}/g}")', + 'StacWidget ${1:screenName}() {', + ' return StacScaffold(', + ' body: StacAlign(', + ' alignment: StacAlignmentDirectional.center,', + ' child: StacPadding(', + ' padding: StacEdgeInsets.all(8),', + " child: StacCenter(child: StacText(data: '${3:Hello, world!}')),", + ' ),', + ' ),', + ' );', + '}', + ], + }, + { + prefix: 'stac theme', + description: 'Create a new Stac theme', + body: [ + "import 'package:stac_core/stac_core.dart';", + '', + '@StacThemeRef(name: "${1/(^[A-Z])|([A-Z])/${1:/downcase}${2:+_}${2:/downcase}/g}")', + 'StacTheme get ${1:lightTheme} => StacTheme(', + ' brightness: StacBrightness.light,', + ' useMaterial3: true,', + ');', + ], + }, +]; + +const STAC_SNIPPET_QUERY_REGEX = /(?:^|\s)(stac(?:\s+[a-z]*)?)$/i; + +export class StacSnippetCompletionProvider implements vscode.CompletionItemProvider { + provideCompletionItems( + document: vscode.TextDocument, + position: vscode.Position, + ): vscode.CompletionItem[] { + if (document.languageId !== 'dart') { + return []; + } + + const config = vscode.workspace.getConfiguration(); + if (!config.get(SETTINGS.enableSnippets, true)) { + return []; + } + + if (!isStacDslDocument(document)) { + return []; + } + + const linePrefix = document.lineAt(position.line).text.slice(0, position.character); + const match = linePrefix.match(STAC_SNIPPET_QUERY_REGEX); + + if (!match) { + return []; + } + + const typedPrefix = (match[1] ?? '').toLowerCase(); + const startCharacter = linePrefix.length - typedPrefix.length; + const replaceRange = new vscode.Range( + new vscode.Position(position.line, startCharacter), + position, + ); + + return SIMPLE_SNIPPETS + .filter((entry) => entry.prefix.startsWith(typedPrefix)) + .map((entry) => { + const item = new vscode.CompletionItem( + entry.prefix, + vscode.CompletionItemKind.Snippet, + ); + + item.detail = entry.description; + item.insertText = new vscode.SnippetString(entry.body.join('\n')); + item.range = replaceRange; + item.filterText = entry.prefix; + item.sortText = entry.prefix; + return item; + }); + } +} diff --git a/tools/stac-vscode/src/test/extension.test.ts b/tools/stac-vscode/src/test/extension.test.ts new file mode 100644 index 000000000..6ca11b020 --- /dev/null +++ b/tools/stac-vscode/src/test/extension.test.ts @@ -0,0 +1,14 @@ +import * as assert from 'assert'; +import { COMMANDS } from '../core/constants'; + +suite('Extension Test Suite', () => { + test('registers expected command ids', () => { + assert.ok(COMMANDS.wrapWithStacContainer); + assert.ok(COMMANDS.wrapWithStacWidget); + assert.ok(COMMANDS.regenerateCatalog); + assert.ok(COMMANDS.previewOpen); + assert.ok(COMMANDS.previewRefresh); + assert.ok(COMMANDS.previewStop); + assert.ok(COMMANDS.previewSelectScreen); + }); +}); diff --git a/tools/stac-vscode/src/test/font_discovery.test.ts b/tools/stac-vscode/src/test/font_discovery.test.ts new file mode 100644 index 000000000..1c947006d --- /dev/null +++ b/tools/stac-vscode/src/test/font_discovery.test.ts @@ -0,0 +1,58 @@ + +import * as assert from 'assert'; +import * as path from 'path'; +import * as fs from 'fs'; +import { findFontsInPubspec } from '../preview/fontDiscovery'; + +suite('Font Discovery Tests', () => { + const testDir = path.join(__dirname, 'font_discovery_test'); + + setup(() => { + if (!fs.existsSync(testDir)) fs.mkdirSync(testDir); + }); + + teardown(() => { + if (fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true }); + }); + + test('findFontsInPubspec returns empty array if no pubspec', async () => { + const fonts = await findFontsInPubspec(testDir); + assert.deepStrictEqual(fonts, []); + }); + + test('findFontsInPubspec returns empty array if no fonts section', async () => { + const pubspec = ` +name: test_project +flutter: + uses-material-design: true +`; + fs.writeFileSync(path.join(testDir, 'pubspec.yaml'), pubspec); + const fonts = await findFontsInPubspec(testDir); + assert.deepStrictEqual(fonts, []); + }); + + test('findFontsInPubspec parses valid fonts', async () => { + const pubspec = ` +name: test_project +flutter: + fonts: + - family: MyFont + fonts: + - asset: assets/fonts/MyFont.ttf + - family: OtherFont + fonts: + - asset: assets/fonts/OtherFont-Regular.ttf + - asset: assets/fonts/OtherFont-Bold.ttf +`; + fs.writeFileSync(path.join(testDir, 'pubspec.yaml'), pubspec); + const fonts = await findFontsInPubspec(testDir); + + assert.strictEqual(fonts.length, 2); + assert.strictEqual(fonts[0].family, 'MyFont'); + assert.strictEqual(fonts[0].fonts.length, 1); + assert.strictEqual(fonts[0].fonts[0].asset, 'assets/fonts/MyFont.ttf'); + + assert.strictEqual(fonts[1].family, 'OtherFont'); + assert.strictEqual(fonts[1].fonts.length, 2); + }); +}); diff --git a/tools/stac-vscode/src/test/preview.jsonResolver.test.ts b/tools/stac-vscode/src/test/preview.jsonResolver.test.ts new file mode 100644 index 000000000..fa6ef1864 --- /dev/null +++ b/tools/stac-vscode/src/test/preview.jsonResolver.test.ts @@ -0,0 +1,39 @@ +import * as assert from 'assert'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { promises as fs } from 'node:fs'; +import { + expandWorkspacePathTokens, + readJsonFile, + resolveScreenJsonPath, +} from '../preview/jsonResolver'; + +suite('Preview JSON resolver', () => { + test('resolveScreenJsonPath finds json in stac/.build', async () => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'stac-vscode-json-')); + const outputDir = path.join(workspace, 'stac', '.build'); + await fs.mkdir(outputDir, { recursive: true }); + const jsonPath = path.join(outputDir, 'hello_world.json'); + await fs.writeFile(jsonPath, '{"type":"text"}', 'utf8'); + + const resolved = resolveScreenJsonPath(workspace, 'hello_world', ['stac/.build']); + assert.strictEqual(resolved, jsonPath); + }); + + test('expandWorkspacePathTokens replaces workspace token', () => { + const expanded = expandWorkspacePathTokens( + '${workspaceFolder}/build/screens', + '/tmp/demo', + ); + assert.strictEqual(expanded, '/tmp/demo/build/screens'); + }); + + test('readJsonFile parses object json', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stac-vscode-json-read-')); + const jsonPath = path.join(tempDir, 'screen.json'); + await fs.writeFile(jsonPath, '{"type":"scaffold"}', 'utf8'); + + const payload = await readJsonFile(jsonPath); + assert.strictEqual(payload.type, 'scaffold'); + }); +}); diff --git a/tools/stac-vscode/src/test/preview.runnerScript.test.ts b/tools/stac-vscode/src/test/preview.runnerScript.test.ts new file mode 100644 index 000000000..1ff73ec45 --- /dev/null +++ b/tools/stac-vscode/src/test/preview.runnerScript.test.ts @@ -0,0 +1,36 @@ +import * as assert from 'assert'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { promises as fs } from 'node:fs'; +import { buildRunnerScript, writeRunnerArtifacts } from '../preview/runnerScript'; + +suite('Preview runner script', () => { + test('buildRunnerScript includes function invocation', () => { + const script = buildRunnerScript( + '/tmp/stac/screens/home.dart', + 'homeScreen', + ); + + assert.ok(script.includes("import 'file:///tmp/stac/screens/home.dart' as target;")); + assert.ok(script.includes('final data = target.homeScreen().toJson();')); + }); + + test('writeRunnerArtifacts creates script and output paths', async () => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), 'stac-vscode-preview-')); + const sourceFile = path.join(workspace, 'stac', 'home.dart'); + await fs.mkdir(path.dirname(sourceFile), { recursive: true }); + await fs.writeFile(sourceFile, '// test', 'utf8'); + + const artifacts = await writeRunnerArtifacts( + workspace, + sourceFile, + 'homeScreen', + 'home_screen', + ); + + const scriptExists = await fs.stat(artifacts.scriptPath); + assert.ok(scriptExists.isFile()); + assert.ok(artifacts.outputPath.endsWith('.json')); + assert.ok(artifacts.outputPath.includes('home_screen')); + }); +}); diff --git a/tools/stac-vscode/src/test/preview.screenDiscovery.test.ts b/tools/stac-vscode/src/test/preview.screenDiscovery.test.ts new file mode 100644 index 000000000..851df34f1 --- /dev/null +++ b/tools/stac-vscode/src/test/preview.screenDiscovery.test.ts @@ -0,0 +1,118 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { chooseScreenDescriptor, discoverScreens } from '../preview/screenDiscovery'; + +function createMockDocument(text: string): vscode.TextDocument { + const lines = text.split('\n'); + + function offsetAt(position: vscode.Position): number { + let offset = 0; + for (let line = 0; line < position.line; line += 1) { + offset += lines[line].length + 1; + } + + return offset + position.character; + } + + function positionAt(offset: number): vscode.Position { + let remaining = offset; + for (let line = 0; line < lines.length; line += 1) { + const lineLength = lines[line].length; + if (remaining <= lineLength) { + return new vscode.Position(line, remaining); + } + + remaining -= lineLength + 1; + } + + return new vscode.Position(lines.length - 1, lines.at(-1)?.length ?? 0); + } + + return { + languageId: 'dart', + uri: vscode.Uri.parse('untitled:preview_test.dart'), + getText: () => text, + lineAt: (line: number) => ({ text: lines[line] }), + offsetAt, + positionAt, + } as unknown as vscode.TextDocument; +} + +suite('Preview screen discovery', () => { + test('detects multiple @StacScreen declarations', () => { + const source = [ + '@StacScreen(screenName: "home")', + 'StacWidget homeScreen() {', + " return StacText(data: 'home');", + '}', + '', + '@StacScreen(screenName: "details")', + 'StacWidget detailsScreen() {', + " return StacText(data: 'details');", + '}', + ].join('\n'); + + const screens = discoverScreens(createMockDocument(source)); + assert.strictEqual(screens.length, 2); + assert.strictEqual(screens[0].screenName, 'home'); + assert.strictEqual(screens[1].functionName, 'detailsScreen'); + assert.ok(screens.every((screen) => screen.runnerSupported)); + }); + + test('marks parameterized screen as runner unsupported', () => { + const source = [ + '@StacScreen(screenName: "profile")', + 'StacWidget profileScreen(String userId) {', + " return StacText(data: userId);", + '}', + ].join('\n'); + + const [screen] = discoverScreens(createMockDocument(source)); + assert.ok(screen); + assert.strictEqual(screen.hasParameters, true); + assert.strictEqual(screen.runnerSupported, false); + }); + + test('defaults to the first screen when cursor is not provided', () => { + const source = [ + '@StacScreen(screenName: "first")', + 'StacWidget firstScreen() {', + " return StacText(data: 'first');", + '}', + '', + '@StacScreen(screenName: "second")', + 'StacWidget secondScreen() {', + " return StacText(data: 'second');", + '}', + ].join('\n'); + + const document = createMockDocument(source); + const screens = discoverScreens(document); + const selected = chooseScreenDescriptor(screens); + + assert.ok(selected); + assert.strictEqual(selected?.screenName, 'first'); + }); + + test('chooses screen containing cursor offset', () => { + const source = [ + '@StacScreen(screenName: "first")', + 'StacWidget firstScreen() {', + " return StacText(data: 'first');", + '}', + '', + '@StacScreen(screenName: "second")', + 'StacWidget secondScreen() {', + " return StacText(data: 'second');", + '}', + ].join('\n'); + + const document = createMockDocument(source); + const screens = discoverScreens(document); + const cursorOffset = source.indexOf("data: 'second'"); + const selected = chooseScreenDescriptor(screens, cursorOffset); + + assert.ok(selected); + assert.strictEqual(selected?.screenName, 'second'); + }); +}); diff --git a/tools/stac-vscode/src/test/preview.strategy.test.ts b/tools/stac-vscode/src/test/preview.strategy.test.ts new file mode 100644 index 000000000..bc3e8449b --- /dev/null +++ b/tools/stac-vscode/src/test/preview.strategy.test.ts @@ -0,0 +1,115 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { generatePreviewJson } from '../preview/jsonGeneration'; + +function createOutputChannel(): vscode.OutputChannel { + return { + name: 'test', + append: () => undefined, + appendLine: () => undefined, + clear: () => undefined, + show: () => undefined, + hide: () => undefined, + replace: () => undefined, + dispose: () => undefined, + } as unknown as vscode.OutputChannel; +} + +suite('Preview JSON strategy', () => { + const baseOptions = { + workspaceRoot: '/tmp/workspace', + sourceFilePath: '/tmp/workspace/stac/home.dart', + screenName: 'home', + functionName: 'homeScreen', + runnerSupported: true, + buildCommand: 'stac build', + outputDirCandidates: ['stac/.build'], + outputChannel: createOutputChannel(), + } as const; + + test('runnerThenBuild uses runner result when fast path succeeds', async () => { + const result = await generatePreviewJson( + { + ...baseOptions, + strategy: 'runnerThenBuild', + }, + { + runRunner: async () => ({ + source: 'runner', + json: { type: 'text' }, + jsonPath: '/tmp/runner.json', + }), + runBuildFallback: async () => { + throw new Error('fallback should not run'); + }, + } as any, + ); + + assert.strictEqual(result.source, 'runner'); + assert.strictEqual(result.jsonPath, '/tmp/runner.json'); + }); + + test('runnerThenBuild falls back when runner fails', async () => { + const result = await generatePreviewJson( + { + ...baseOptions, + strategy: 'runnerThenBuild', + }, + { + runRunner: async () => { + throw new Error('runner failed'); + }, + runBuildFallback: async () => ({ + json: { type: 'scaffold' }, + jsonPath: '/tmp/build.json', + }), + } as any, + ); + + assert.strictEqual(result.source, 'build'); + assert.strictEqual(result.jsonPath, '/tmp/build.json'); + }); + + test('runnerOnly fails when runner fails', async () => { + await assert.rejects( + generatePreviewJson( + { + ...baseOptions, + strategy: 'runnerOnly', + }, + { + runRunner: async () => { + throw new Error('runner failed'); + }, + runBuildFallback: async () => ({ + json: { type: 'fallback' }, + jsonPath: '/tmp/fallback.json', + }), + } as any, + ), + ); + }); + + test('buildOnly skips runner and uses fallback directly', async () => { + const result = await generatePreviewJson( + { + ...baseOptions, + strategy: 'buildOnly', + }, + { + runRunner: async () => ({ + source: 'runner', + json: { type: 'runner' }, + jsonPath: '/tmp/runner.json', + }), + runBuildFallback: async () => ({ + json: { type: 'build' }, + jsonPath: '/tmp/build.json', + }), + } as any, + ); + + assert.strictEqual(result.source, 'build'); + assert.strictEqual(result.jsonPath, '/tmp/build.json'); + }); +}); diff --git a/tools/stac-vscode/src/test/preview_assets.test.ts b/tools/stac-vscode/src/test/preview_assets.test.ts new file mode 100644 index 000000000..9912fad55 --- /dev/null +++ b/tools/stac-vscode/src/test/preview_assets.test.ts @@ -0,0 +1,130 @@ + +import * as assert from 'assert'; +import * as http from 'http'; +import * as path from 'path'; +import * as fs from 'fs'; +import { AssetServer } from '../preview/assetServer'; +import { transformJson } from '../preview/jsonTransformer'; + +suite('Preview Assets Tests', () => { + let server: AssetServer; + let itemsToDelete: string[] = []; + + setup(() => { + server = new AssetServer((_msg) => { }); + }); + + teardown(() => { + server.stop(); + itemsToDelete.forEach(p => { + if (fs.existsSync(p)) { + try { + const stat = fs.statSync(p); + if (stat.isDirectory()) { + fs.rmSync(p, { recursive: true, force: true }); + } else { + fs.unlinkSync(p); + } + } catch (e) { + console.error(`Failed to cleanup ${p}:`, e); + } + } + }); + itemsToDelete = []; + }); + + test('AssetServer serves file correctly', async () => { + const workspaceRoot = path.join(__dirname, 'assets_test_ws'); + if (!fs.existsSync(workspaceRoot)) fs.mkdirSync(workspaceRoot); + const testFile = path.join(workspaceRoot, 'test.png'); + fs.writeFileSync(testFile, 'fake-image-data'); + itemsToDelete.push(testFile); + itemsToDelete.push(path.join(workspaceRoot)); // cleanup dir in teardown if empty, logic simplified here + + const port = await server.start(workspaceRoot); + + const response = await new Promise<{ statusCode: number, data: string }>((resolve, reject) => { + http.get(`http://127.0.0.1:${port}/test.png`, (res) => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => resolve({ statusCode: res.statusCode ?? 0, data })); + }).on('error', reject); + }); + + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(response.data, 'fake-image-data'); + + // Cleanup handled in teardown + }); + + test('AssetServer serves font file correctly', async () => { + const workspaceRoot = path.join(__dirname, 'assets_test_ws_fonts'); + if (fs.existsSync(workspaceRoot)) fs.rmSync(workspaceRoot, { recursive: true, force: true }); + fs.mkdirSync(workspaceRoot); + + const testFile = path.join(workspaceRoot, 'test.ttf'); + fs.writeFileSync(testFile, 'fake-font-data'); + itemsToDelete.push(workspaceRoot); + + const port = await server.start(workspaceRoot); + + const response = await new Promise<{ statusCode: number, headers: any }>((resolve, reject) => { + http.get(`http://127.0.0.1:${port}/test.ttf`, (res) => { + res.resume(); + resolve({ statusCode: res.statusCode ?? 0, headers: res.headers }); + }).on('error', reject); + }); + + assert.strictEqual(response.statusCode, 200); + assert.strictEqual(response.headers['content-type'], 'font/ttf'); + }); + + test('jsonTransformer rewrites asset urls', () => { + const port = 1234; + const input = { + type: 'image', + imageType: 'asset', + src: 'assets/logo.png', + width: 100 + }; + + const output = transformJson(input, port); + + assert.strictEqual(output.imageType, 'network'); + assert.strictEqual(output.src, `http://127.0.0.1:${port}/assets/logo.png`); + assert.strictEqual(output.width, 100); + }); + + test('jsonTransformer handles nested structures', () => { + const port = 1234; + const input = { + type: 'column', + children: [ + { + type: 'image', + imageType: 'asset', + src: 'assets/1.png' + }, + { + type: 'row', + children: [ + { + type: 'image', + imageType: 'network', + src: 'http://example.com/2.png' + } + ] + } + ] + }; + + const output = transformJson(input, port); + + assert.strictEqual(output.children[0].imageType, 'network'); + assert.strictEqual(output.children[0].src, `http://127.0.0.1:${port}/assets/1.png`); + + // Should not touch network images + assert.strictEqual(output.children[1].children[0].imageType, 'network'); + assert.strictEqual(output.children[1].children[0].src, 'http://example.com/2.png'); + }); +}); diff --git a/tools/stac-vscode/src/test/providers.test.ts b/tools/stac-vscode/src/test/providers.test.ts new file mode 100644 index 000000000..760129bc2 --- /dev/null +++ b/tools/stac-vscode/src/test/providers.test.ts @@ -0,0 +1,96 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { StacSnippetCompletionProvider } from '../snippets/stacSnippetCompletionProvider'; +import { StacWrapCodeActionProvider } from '../wrap/stacWrapCodeActionProvider'; + +suite('Providers', () => { + test('quick fix list appears on stac widget expression', async () => { + const source = "Widget build() => StacText(data: 'Hello');"; + const document = await vscode.workspace.openTextDocument({ + language: 'dart', + content: source, + }); + + const provider = new StacWrapCodeActionProvider(); + const offset = source.indexOf('StacText') + 2; + const position = document.positionAt(offset); + const range = new vscode.Range(position, position); + + const actions = provider.provideCodeActions(document, range) as vscode.CodeAction[]; + const titles = actions.map((action) => action.title); + + assert.ok(titles.includes('Wrap with StacContainer')); + assert.ok(titles.includes('Wrap with StacPadding')); + assert.ok(titles.includes('Wrap with StacCenter')); + assert.ok(titles.includes('Wrap with StacAlign')); + assert.ok(titles.includes('Wrap with StacSizedBox')); + assert.ok(titles.includes('Wrap with StacExpanded')); + assert.ok(titles.includes('Wrap with Stac widget')); + }); + + test('quick fix list does not appear on non-stac constructors', async () => { + const source = "Widget build() => Text('Hello');"; + const document = await vscode.workspace.openTextDocument({ + language: 'dart', + content: source, + }); + + const provider = new StacWrapCodeActionProvider(); + const offset = source.indexOf('Text') + 1; + const position = document.positionAt(offset); + const range = new vscode.Range(position, position); + + const actions = provider.provideCodeActions(document, range) as vscode.CodeAction[]; + assert.strictEqual(actions.length, 0); + }); + + test('snippet provider only suggests in stac dsl context', async () => { + const provider = new StacSnippetCompletionProvider(); + + const dslSource = [ + "import 'package:stac_core/stac_core.dart';", + 'void buildStac() {', + ' stac ', + '}', + ].join('\n'); + + const dslDocument = await vscode.workspace.openTextDocument({ + language: 'dart', + content: dslSource, + }); + + const dslPosition = dslDocument.positionAt( + dslSource.indexOf('stac ') + 'stac '.length, + ); + const dslItems = provider.provideCompletionItems( + dslDocument, + dslPosition, + ) as vscode.CompletionItem[]; + + assert.ok(dslItems.some((item) => item.label === 'stac screen')); + assert.ok(dslItems.some((item) => item.label === 'stac theme')); + assert.strictEqual(dslItems.length, 2); + + const plainSource = [ + 'void notDsl() {', + ' stac theme', + '}', + ].join('\n'); + + const plainDocument = await vscode.workspace.openTextDocument({ + language: 'dart', + content: plainSource, + }); + + const plainPosition = plainDocument.positionAt( + plainSource.indexOf('stac theme') + 'stac theme'.length, + ); + + const plainItems = provider.provideCompletionItems( + plainDocument, + plainPosition, + ) as vscode.CompletionItem[]; + + assert.strictEqual(plainItems.length, 0); + }); +}); diff --git a/tools/stac-vscode/src/test/remove.test.ts b/tools/stac-vscode/src/test/remove.test.ts new file mode 100644 index 000000000..bc1641889 --- /dev/null +++ b/tools/stac-vscode/src/test/remove.test.ts @@ -0,0 +1,141 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { findChildExpression, createRemoveWidgetEdit } from '../wrap/removeWidget'; +import { findWrappableExpression } from '../wrap/findWrappableExpression'; + +function createMockDocument(text: string): vscode.TextDocument { + const lines = text.split('\n'); + + function offsetAt(position: vscode.Position): number { + let offset = 0; + for (let line = 0; line < position.line; line += 1) { + offset += lines[line].length + 1; + } + + return offset + position.character; + } + + function positionAt(offset: number): vscode.Position { + let remaining = offset; + for (let line = 0; line < lines.length; line += 1) { + const lineLength = lines[line].length; + if (remaining <= lineLength) { + return new vscode.Position(line, remaining); + } + + remaining -= lineLength + 1; + } + + return new vscode.Position(lines.length - 1, lines.at(-1)?.length ?? 0); + } + + return { + languageId: 'dart', + uri: vscode.Uri.parse('untitled:mock.dart'), + getText: (range?: vscode.Range) => { + if (!range) { + return text; + } + + const start = offsetAt(range.start); + const end = offsetAt(range.end); + return text.slice(start, end); + }, + lineAt: (line: number) => ({ text: lines[line] }), + offsetAt, + positionAt, + } as unknown as vscode.TextDocument; +} + +suite('Remove Widget Utilities', () => { + test('finds child expression in simple widget', () => { + const expr = "StacCenter(child: StacText('Hello'))"; + const child = findChildExpression(expr); + assert.strictEqual(child, "StacText('Hello')"); + }); + + test('finds child expression in multiline widget', () => { + const expr = `StacCenter( + child: StacText( + 'Hello' + ), + )`; + const child = findChildExpression(expr); + // It should capture the content essentially + // My simple regex finds "StacText(\n 'Hello'\n )" + // Let's see what the implementation does. + assert.ok(child?.startsWith('StacText')); + assert.ok(child?.includes("'Hello'")); + }); + + test('returns undefined if no child', () => { + const expr = "StacSizedBox(width: 10)"; + const child = findChildExpression(expr); + assert.strictEqual(child, undefined); + }); + + test('ignores child in nested widget', () => { + // "child:" is present but inside another widget's args + // StacColumn(children: [StacContainer(child: StacText('Hi'))]) + // The outer widget has no "child:", only "children:". + // findChildExpression checks for top-level "child:". + const expr = "StacColumn(children: [StacContainer(child: StacText('Hi'))])"; + const child = findChildExpression(expr); + assert.strictEqual(child, undefined); + }); + + test('handles complex nesting', () => { + const expr = `StacContainer( + child: StacRow( + children: [StacText('A')], + ), + )`; + const child = findChildExpression(expr); + // It matches content of child: ... + // "StacRow(\n children: [StacText('A')],\n )" + assert.ok(child?.startsWith('StacRow')); + }); + + test('handles comments correctly', () => { + const expr = `StacPadding( + // child: StacText('Ignored'), + child: StacText('Real'), + )`; + const child = findChildExpression(expr); + assert.strictEqual(child, "StacText('Real')"); + }); + + test('handles strings correctly', () => { + const expr = `StacColumn( + children: [ + StacText("child: Not Real"), + ], + child: StacText("Real"), + )`; + const child = findChildExpression(expr); + assert.strictEqual(child, 'StacText("Real")'); + }); + test('create edit replaces widget with child', () => { + const source = + ` StacCenter( + child: StacText('Hello'), + )`; + const doc = createMockDocument(source); + const target = { + widgetName: 'StacCenter', + range: new vscode.Range(new vscode.Position(0, 2), new vscode.Position(2, 3)), + expression: source.trim() + }; + + const edit = createRemoveWidgetEdit(doc, target); + const entries = edit.entries(); + assert.strictEqual(entries.length, 1); + + // Check replacement text (should be StacText('Hello')) + // Depending on reindent logic... + // The mock logic for reindent might be tricky to test perfectly without full editor behavior, + // but we can check the string content. + const replacement = entries[0][1][0].newText; + assert.ok(replacement.includes("StacText('Hello')")); + }); +}); diff --git a/tools/stac-vscode/src/test/wrap.test.ts b/tools/stac-vscode/src/test/wrap.test.ts new file mode 100644 index 000000000..d478e0da1 --- /dev/null +++ b/tools/stac-vscode/src/test/wrap.test.ts @@ -0,0 +1,188 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { buildWrappedExpression } from '../wrap/applyWrapEdit'; +import { findWrappableExpression } from '../wrap/findWrappableExpression'; +import { widgetCatalogByClass } from '../generated/widgetCatalog'; +import { validateCustomWrapperName } from '../wrap/pickCustomWrapper'; +import { templateFromWidgetCatalog } from '../wrap/wrapperTemplates'; + +function createMockDocument(text: string): vscode.TextDocument { + const lines = text.split('\n'); + + function offsetAt(position: vscode.Position): number { + let offset = 0; + for (let line = 0; line < position.line; line += 1) { + offset += lines[line].length + 1; + } + + return offset + position.character; + } + + function positionAt(offset: number): vscode.Position { + let remaining = offset; + for (let line = 0; line < lines.length; line += 1) { + const lineLength = lines[line].length; + if (remaining <= lineLength) { + return new vscode.Position(line, remaining); + } + + remaining -= lineLength + 1; + } + + return new vscode.Position(lines.length - 1, lines.at(-1)?.length ?? 0); + } + + return { + languageId: 'dart', + uri: vscode.Uri.parse('untitled:mock.dart'), + getText: (range?: vscode.Range) => { + if (!range) { + return text; + } + + const start = offsetAt(range.start); + const end = offsetAt(range.end); + return text.slice(start, end); + }, + lineAt: (line: number) => ({ text: lines[line] }), + offsetAt, + positionAt, + } as unknown as vscode.TextDocument; +} + +suite('Wrap utilities', () => { + test('finds nearest Stac widget at cursor', () => { + const source = "final widget = StacPadding(child: StacText(data: 'Hello'));"; + const document = createMockDocument(source); + const cursorOffset = source.indexOf('StacText') + 5; + const cursorPosition = document.positionAt(cursorOffset); + + const target = findWrappableExpression( + document, + new vscode.Range(cursorPosition, cursorPosition), + ); + + assert.ok(target); + assert.strictEqual(target?.widgetName, 'StacText'); + assert.strictEqual(target?.expression, "StacText(data: 'Hello')"); + }); + + test('returns undefined when selection is not on stac widget', () => { + const source = "final widget = Text('Hello');"; + const document = createMockDocument(source); + const cursorOffset = source.indexOf('Text') + 2; + const cursorPosition = document.positionAt(cursorOffset); + + const target = findWrappableExpression( + document, + new vscode.Range(cursorPosition, cursorPosition), + ); + + assert.strictEqual(target, undefined); + }); + + test('builds wrapped expression with child property (single-line)', () => { + // baseIndent=' ' simulates widget on a line with 2-space indent + // First line should NOT include baseIndent (replacement starts mid-line) + const wrapped = buildWrappedExpression( + { + wrapperName: 'StacContainer', + title: 'Wrap with StacContainer', + childMode: 'child', + beforeChildArgs: [], + }, + "StacText(data: 'Hello')", + ' ', + ' ', + ); + + assert.strictEqual( + wrapped, + "StacContainer(\n child: StacText(data: 'Hello'),\n )", + ); + }); + + test('builds wrapped expression with multiline child (preserves relative indent)', () => { + // Simulates expression captured mid-line: first line has no indent, + // rest lines have document indent that gets dedented. + const inner = `StacAlign( + alignment: StacAlignmentDirectional.center, + child: StacCenter(child: StacText(data: 'Hi')), +)`; + const wrapped = buildWrappedExpression( + { + wrapperName: 'StacWidget', + title: 'Wrap with Stac widget', + childMode: 'child', + beforeChildArgs: [], + }, + inner, + ' ', + ' ', + ); + + assert.strictEqual( + wrapped, + `StacWidget( + child: StacAlign( + alignment: StacAlignmentDirectional.center, + child: StacCenter(child: StacText(data: 'Hi')), + ), + )`, + ); + }); + + test('builds wrapped expression with beforeChildArgs (e.g. StacPadding)', () => { + const wrapped = buildWrappedExpression( + { + wrapperName: 'StacPadding', + title: 'Wrap with StacPadding', + childMode: 'child', + beforeChildArgs: ['padding: StacEdgeInsets.all(8)'], + }, + "StacCenter(child: StacText(data: 'Hi'))", + ' ', + ' ', + ); + + assert.strictEqual( + wrapped, + "StacPadding(\n padding: StacEdgeInsets.all(8),\n child: StacCenter(child: StacText(data: 'Hi')),\n )", + ); + }); + + test('builds wrapped expression with children property', () => { + const wrapped = buildWrappedExpression( + { + wrapperName: 'StacColumn', + title: 'Wrap with StacColumn', + childMode: 'children', + beforeChildArgs: [], + }, + "StacText(data: 'Hello')", + '', + ' ', + ); + + assert.strictEqual( + wrapped, + "StacColumn(\n children: [\n StacText(data: 'Hello'),\n ],\n)", + ); + }); + + test('custom wrapper validator accepts and rejects correctly', () => { + assert.strictEqual(validateCustomWrapperName('StacColumn'), undefined); + assert.ok(validateCustomWrapperName('Column')); + assert.ok(validateCustomWrapperName('StacText')); + assert.ok(validateCustomWrapperName('StacUnknownWidget')); + }); + + test('template generation chooses children mode when available', () => { + const widget = widgetCatalogByClass.get('StacColumn'); + assert.ok(widget); + + const template = templateFromWidgetCatalog(widget!); + assert.ok(template); + assert.strictEqual(template?.childMode, 'children'); + }); +}); diff --git a/tools/stac-vscode/src/wrap/applyWrapEdit.ts b/tools/stac-vscode/src/wrap/applyWrapEdit.ts new file mode 100644 index 000000000..615d4a185 --- /dev/null +++ b/tools/stac-vscode/src/wrap/applyWrapEdit.ts @@ -0,0 +1,182 @@ +import * as vscode from 'vscode'; +import type { WrappableExpressionTarget } from './findWrappableExpression'; +import type { WrapperTemplate } from './wrapperTemplates'; + +const INDENT_UNIT = ' '; + +/** + * Get the leading whitespace of the line where the expression starts. + * Used as base indent for lines 2+ of the wrapped output. + */ +function getBaseIndent(document: vscode.TextDocument, range: vscode.Range): string { + const lineText = document.lineAt(range.start.line).text; + const match = lineText.match(/^\s*/); + return match ? match[0] : ''; +} + +function stripTrailingComma(expression: string): string { + return expression.trim().replace(/,\s*$/, ''); +} + +/** + * Dedent a multiline expression captured from the document. + * + * The first line has no leading whitespace (captured mid-line at the widget name). + * Lines 2+ have full document indentation. We compute common indent from lines 2+ + * and strip it, preserving relative indentation within the expression. + */ +function dedentExpression(expression: string): string[] { + const lines = expression.split('\n'); + if (lines.length <= 1) { + return [lines[0].trim()]; + } + + const restLines = lines.slice(1); + const restIndents = restLines + .filter((line) => line.trim().length > 0) + .map((line) => { + const match = line.match(/^\s*/); + return match ? match[0].length : 0; + }); + + const minIndent = restIndents.length > 0 ? Math.min(...restIndents) : 0; + + return [ + lines[0].trim(), + ...restLines.map((line) => { + if (line.trim().length === 0) { + return ''; + } + const match = line.match(/^\s*/); + const leadingLen = match ? match[0].length : 0; + return line.slice(Math.min(leadingLen, minIndent)); + }), + ]; +} + +function appendCommaToLastLine(lines: string[]): string[] { + if (lines.length === 0) { + return lines; + } + + const last = lines.length - 1; + if (!lines[last].trimEnd().endsWith(',')) { + lines[last] = `${lines[last]},`; + } + + return lines; +} + +/** + * Build lines for `child: `. + * + * Single-line: ` child: Expression(),` + * Multiline: ` child: Expression(\n ...\n ),` + * + * Rest lines are indented at `innerIndent` and preserve their relative structure + * from the dedented expression. + */ +function buildChildLine( + expression: string, + baseIndent: string, + indentUnit: string, +): string[] { + const innerIndent = `${baseIndent}${indentUnit}`; + + if (!expression.includes('\n')) { + return [`${innerIndent}child: ${expression.trim()},`]; + } + + const dedented = dedentExpression(expression); + const firstLine = `${innerIndent}child: ${dedented[0]}`; + const restLines = dedented.slice(1).map((line) => + line.length === 0 ? '' : `${innerIndent}${line}`, + ); + return appendCommaToLastLine([firstLine, ...restLines]); +} + +/** + * Build lines for `children: []`. + */ +function buildChildrenLines( + expression: string, + baseIndent: string, + indentUnit: string, +): string[] { + const innerIndent = `${baseIndent}${indentUnit}`; + const childIndent = `${innerIndent}${indentUnit}`; + + const lines = [`${innerIndent}children: [`]; + + if (!expression.includes('\n')) { + lines.push(`${childIndent}${expression.trim()},`); + lines.push(`${innerIndent}],`); + return lines; + } + + const dedented = dedentExpression(expression); + const reindented = dedented.map((line) => + line.length === 0 ? '' : `${childIndent}${line}`, + ); + lines.push(...appendCommaToLastLine(reindented)); + lines.push(`${innerIndent}],`); + return lines; +} + +/** + * Build the full wrapped expression string. + * + * The FIRST line has NO baseIndent because the replacement starts at the widget's + * position in the line (which may be mid-line, e.g. after `body: `). + * Lines 2+ use baseIndent since they start from column 0 in the replacement text. + */ +export function buildWrappedExpression( + template: WrapperTemplate, + expression: string, + baseIndent: string, + indentUnit: string = INDENT_UNIT, +): string { + const normalizedExpression = stripTrailingComma(expression); + const innerIndent = `${baseIndent}${indentUnit}`; + + // First line: NO baseIndent — starts at the original widget position + const lines = [`${template.wrapperName}(`]; + for (const arg of template.beforeChildArgs) { + lines.push(`${innerIndent}${arg},`); + } + + if (template.childMode === 'children') { + lines.push(...buildChildrenLines(normalizedExpression, baseIndent, indentUnit)); + } else { + lines.push(...buildChildLine(normalizedExpression, baseIndent, indentUnit)); + } + + lines.push(`${baseIndent})`); + return lines.join('\n'); +} + +export function createWrapWorkspaceEdit( + document: vscode.TextDocument, + target: WrappableExpressionTarget, + template: WrapperTemplate, +): vscode.WorkspaceEdit { + const baseIndent = getBaseIndent(document, target.range); + const wrappedExpression = buildWrappedExpression( + template, + target.expression, + baseIndent, + ); + + const edit = new vscode.WorkspaceEdit(); + edit.replace(document.uri, target.range, wrappedExpression); + return edit; +} + +export async function applyWrapWorkspaceEdit( + document: vscode.TextDocument, + target: WrappableExpressionTarget, + template: WrapperTemplate, +): Promise { + const edit = createWrapWorkspaceEdit(document, target, template); + return vscode.workspace.applyEdit(edit); +} diff --git a/tools/stac-vscode/src/wrap/findWrappableExpression.ts b/tools/stac-vscode/src/wrap/findWrappableExpression.ts new file mode 100644 index 000000000..80bf3adef --- /dev/null +++ b/tools/stac-vscode/src/wrap/findWrappableExpression.ts @@ -0,0 +1,161 @@ +import * as vscode from 'vscode'; + +export interface StacExpressionRange { + widgetName: string; + startOffset: number; + endOffset: number; +} + +export interface WrappableExpressionTarget { + widgetName: string; + range: vscode.Range; + expression: string; +} + +export function collectStacExpressionRanges(text: string): StacExpressionRange[] { + const matches = [...text.matchAll(/\b(Stac[A-Za-z0-9_]+)\s*\(/g)]; + const ranges: StacExpressionRange[] = []; + + for (const match of matches) { + const widgetName = match[1]; + const matchIndex = match.index; + + if (widgetName === undefined || matchIndex === undefined) { + continue; + } + + const openParenOffset = matchIndex + match[0].lastIndexOf('('); + const closeParenOffset = findMatchingParen(text, openParenOffset); + + if (closeParenOffset < 0) { + continue; + } + + ranges.push({ + widgetName, + startOffset: matchIndex, + endOffset: closeParenOffset + 1, + }); + } + + return ranges; +} + +function findMatchingParen(text: string, openParenOffset: number): number { + type State = 'normal' | 'single' | 'double' | 'lineComment' | 'blockComment'; + let state: State = 'normal'; + let escaped = false; + let depth = 0; + + for (let index = openParenOffset; index < text.length; index += 1) { + const char = text[index]; + const next = text[index + 1]; + + if (state === 'lineComment') { + if (char === '\n') { + state = 'normal'; + } + continue; + } + + if (state === 'blockComment') { + if (char === '*' && next === '/') { + state = 'normal'; + index += 1; + } + continue; + } + + if (state === 'single') { + if (!escaped && char === "'") { + state = 'normal'; + } + escaped = !escaped && char === '\\'; + continue; + } + + if (state === 'double') { + if (!escaped && char === '"') { + state = 'normal'; + } + escaped = !escaped && char === '\\'; + continue; + } + + if (char === '/' && next === '/') { + state = 'lineComment'; + index += 1; + continue; + } + + if (char === '/' && next === '*') { + state = 'blockComment'; + index += 1; + continue; + } + + if (char === "'") { + state = 'single'; + escaped = false; + continue; + } + + if (char === '"') { + state = 'double'; + escaped = false; + continue; + } + + if (char === '(') { + depth += 1; + continue; + } + + if (char === ')') { + depth -= 1; + if (depth === 0) { + return index; + } + } + } + + return -1; +} + +export function findWrappableExpression( + document: vscode.TextDocument, + selection: vscode.Range, +): WrappableExpressionTarget | undefined { + const text = document.getText(); + const startOffset = document.offsetAt(selection.start); + const endOffset = document.offsetAt(selection.end); + const isCursor = selection.isEmpty; + + const ranges = collectStacExpressionRanges(text).filter((item) => { + if (isCursor) { + return item.startOffset <= startOffset && startOffset <= item.endOffset; + } + + return item.startOffset <= startOffset && endOffset <= item.endOffset; + }); + + if (ranges.length === 0) { + return undefined; + } + + ranges.sort( + (first, second) => + first.endOffset - first.startOffset - (second.endOffset - second.startOffset), + ); + + const target = ranges[0]; + const start = document.positionAt(target.startOffset); + const end = document.positionAt(target.endOffset); + const range = new vscode.Range(start, end); + + return { + widgetName: target.widgetName, + range, + expression: document.getText(range), + }; +} diff --git a/tools/stac-vscode/src/wrap/pickCustomWrapper.ts b/tools/stac-vscode/src/wrap/pickCustomWrapper.ts new file mode 100644 index 000000000..263599b3f --- /dev/null +++ b/tools/stac-vscode/src/wrap/pickCustomWrapper.ts @@ -0,0 +1,46 @@ +import * as vscode from 'vscode'; +import { widgetCatalogByClass } from '../generated/widgetCatalog'; +import { templateFromWidgetCatalog } from './wrapperTemplates'; + +export async function pickCustomWrapperTemplate() { + const value = await vscode.window.showInputBox({ + title: 'Wrap with Stac widget', + prompt: 'Enter a Stac widget class name (example: StacOpacity)', + placeHolder: 'StacContainer', + validateInput: (input) => validateCustomWrapperName(input), + }); + + if (!value) { + return undefined; + } + + const widget = widgetCatalogByClass.get(value.trim()); + if (!widget) { + return undefined; + } + + return templateFromWidgetCatalog(widget); +} + +export function validateCustomWrapperName(input: string): string | undefined { + const value = input.trim(); + + if (value.length === 0) { + return undefined; + } + + if (!value.startsWith('Stac')) { + return 'Widget name must start with "Stac".'; + } + + const widget = widgetCatalogByClass.get(value); + if (!widget) { + return 'Unknown Stac widget.'; + } + + if (!widget.supportsChild && !widget.supportsChildren) { + return 'This widget does not support child or children wrapping.'; + } + + return undefined; +} diff --git a/tools/stac-vscode/src/wrap/removeWidget.ts b/tools/stac-vscode/src/wrap/removeWidget.ts new file mode 100644 index 000000000..3f219aaeb --- /dev/null +++ b/tools/stac-vscode/src/wrap/removeWidget.ts @@ -0,0 +1,243 @@ +import * as vscode from 'vscode'; +import type { WrappableExpressionTarget } from './findWrappableExpression'; + +const MATCH_CHILD_PROP = /child:\s*/; + +/** + * Finds the expression for the `child` property of a widget. + * + * Logic: + * 1. Find `child:` in the widget expression. + * 2. Extract the value assigned to `child`. + * 3. Handle trailing comma. + */ +export function findChildExpression(expression: string): string | undefined { + let state: 'normal' | 'single' | 'double' | 'lineComment' | 'blockComment' = 'normal'; + let depth = 0; + let foundChildStart = -1; + const childKey = 'child:'; + + for (let i = 0; i < expression.length; i++) { + const char = expression[i]; + const next = expression[i + 1]; + + if (state === 'lineComment') { + if (char === '\n') state = 'normal'; + continue; + } + if (state === 'blockComment') { + if (char === '*' && next === '/') { + state = 'normal'; + i++; + } + continue; + } + if (state === 'single') { + if (char === "'" && expression[i - 1] !== '\\') state = 'normal'; + continue; + } + if (state === 'double') { + if (char === '"' && expression[i - 1] !== '\\') state = 'normal'; + continue; + } + + // State detection + if (char === '/' && next === '/') { + state = 'lineComment'; + i++; + continue; + } + if (char === '/' && next === '*') { + state = 'blockComment'; + i++; + continue; + } + if (char === "'") { + state = 'single'; + continue; + } + if (char === '"') { + state = 'double'; + continue; + } + + // Normal state processing + if (char === '(') { + depth++; + continue; + } + if (char === ')') { + depth--; + continue; + } + + if (depth === 1) { + // Check for "child:" + if (expression.substr(i, childKey.length) === childKey) { + // Boundary check + const prevChar = expression[i - 1]; + if (!/[a-zA-Z0-9_]/.test(prevChar || '')) { + foundChildStart = i + childKey.length; + break; + } + } + } + } + + if (foundChildStart === -1) { + return undefined; + } + + // Extract value + let valueStart = foundChildStart; + while (valueStart < expression.length && /\s/.test(expression[valueStart])) { + valueStart++; + } + + let valueEnd = -1; + depth = 1; + state = 'normal'; + + for (let i = valueStart; i < expression.length; i++) { + const char = expression[i]; + const next = expression[i + 1]; + + // Re-use state logic (simplified copy) + if (state === 'lineComment') { + if (char === '\n') state = 'normal'; + continue; + } + if (state === 'blockComment') { + if (char === '*' && next === '/') { state = 'normal'; i++; } + continue; + } + if (state === 'single') { + if (char === "'" && expression[i - 1] !== '\\') state = 'normal'; + continue; + } + if (state === 'double') { + if (char === '"' && expression[i - 1] !== '\\') state = 'normal'; + continue; + } + + if (char === '/' && next === '/') { state = 'lineComment'; i++; continue; } + if (char === '/' && next === '*') { state = 'blockComment'; i++; continue; } + if (char === "'") { state = 'single'; continue; } + if (char === '"') { state = 'double'; continue; } + + if (char === '(') { depth++; } + else if (char === ')') { + depth--; + if (depth === 0) { + valueEnd = i; + break; + } + } else if (char === ',' && depth === 1) { + valueEnd = i; + break; + } + } + + if (valueEnd === -1) { + // Fallback if we hit end of string without closing paren/comma (malformed or just end) + // But since we started at depth 1 inside a valid widget (presumably), we should hit ')' eventually. + return undefined; + } + + return expression.substring(valueStart, valueEnd).trim(); +} + +export function createRemoveWidgetEdit( + document: vscode.TextDocument, + target: WrappableExpressionTarget, +): vscode.WorkspaceEdit { + const childExpression = findChildExpression(target.expression); + const edit = new vscode.WorkspaceEdit(); + + if (!childExpression) { + return edit; + } + + // We need to re-indent the child expression to match the start of the removed widget. + // The logic is similar to `applyWrapEdit` but in reverse-ish or simpler. + // `target.range.start` is where the widget starts. + // If we just replace, we might mess up indentation if `childExpression` is multi-line. + + // If we assume `childExpression` was already formatted, it has some indentation relative to parent. + // If we promote it, we likely need to dedent it. + + // Let's use `dedentExpression` logic from `applyWrapEdit` if we can export it, or rewrite a simple one. + // Actually, simple replace might be "okay" if we let VS Code format it? + // But better to try to be nice. + + // Let's grab the base indentation of the line where widget starts. + // But wait, `childExpression` extracted from `target.expression` (which comes from `document.getText()`) + // preserves original whitespace. + + // Implementation: + // 1. Dedent `childExpression` so its first line has no indent (it's trimmed already by findChildExpression). + // 2. But subsequent lines in `childExpression` have indentation relative to the *old* widget. + // We need to shift them left. + + // Let's rely on a helper to dedent, similar to `applyWrapEdit.ts`. + // I will duplicate `dedentExpression` logic here for simplicity to avoid circular deps or complex refactor, + // or I can make it shared. `applyWrapEdit.ts` exports `buildWrappedExpression`, not dedent. + // I'll make a small local helper. + + const indentedChild = reindentChild(childExpression, document, target.range); + + edit.replace(document.uri, target.range, indentedChild); + return edit; +} + +function reindentChild(childExpression: string, document: vscode.TextDocument, range: vscode.Range): string { + const lines = childExpression.split('\n'); + if (lines.length <= 1) { + return childExpression; + } + + // The first line is already trimmed by findChildExpression (usually). + // The subsequent lines have indentation. + // We need to calculate how much to remove. + // The previous indentation basis was likely `range.start.character` + some offset. + + // Heuristic: finding the common indentation of lines 2+ and reducing it + // to match `range.start.character`'s indentation? + // Or just "strip common prefix" and add `range.start` indent? + + // Let's try: get base indent of the target line. + const lineText = document.lineAt(range.start.line).text; + const baseIndentMatch = lineText.match(/^\s*/); + const baseIndent = baseIndentMatch ? baseIndentMatch[0] : ''; + + // Check indentation of lines 2+ + const restLines = lines.slice(1); + const indents = restLines + .filter(l => l.trim().length > 0) + .map(l => (l.match(/^\s*/) || [''])[0].length); + + if (indents.length === 0) return childExpression; + + const minIndent = Math.min(...indents); + + // We want to reduce indentation by some amount. + // If we assume the child was indented by +2 spaces relative to parent, + // we probably want to reduce by 2 spaces. + // BUT, we don't know the unit reliably. + + // Alternative: + // 1. Dedent completely (remove common indent). + // 2. Add `baseIndent` to lines 2+. + + const dedentedRest = restLines.map(line => { + if (line.trim().length === 0) return ''; + return line.slice(minIndent); // Remove all common indent + }); + + const result = [ + lines[0], // First line stays as is (it's the expression start) + ...dedentedRest.map(l => l ? baseIndent + l : '') + ]; + + return result.join('\n'); +} diff --git a/tools/stac-vscode/src/wrap/stacWrapCodeActionProvider.ts b/tools/stac-vscode/src/wrap/stacWrapCodeActionProvider.ts new file mode 100644 index 000000000..a707a68de --- /dev/null +++ b/tools/stac-vscode/src/wrap/stacWrapCodeActionProvider.ts @@ -0,0 +1,93 @@ +import * as vscode from 'vscode'; +import { COMMANDS, SETTINGS, WRAP_PRESET_IDS, type WrapPresetId } from '../core/constants'; +import { findWrappableExpression } from './findWrappableExpression'; +import { findChildExpression } from './removeWidget'; +import { getPresetWrapper } from './wrapperTemplates'; + +const WRAP_COMMANDS: Record = { + StacContainer: COMMANDS.wrapWithStacContainer, + StacPadding: COMMANDS.wrapWithStacPadding, + StacCenter: COMMANDS.wrapWithStacCenter, + StacAlign: COMMANDS.wrapWithStacAlign, + StacSizedBox: COMMANDS.wrapWithStacSizedBox, + StacExpanded: COMMANDS.wrapWithStacExpanded, +}; + +function getEnabledPresetIds(): WrapPresetId[] { + const config = vscode.workspace.getConfiguration(); + const configured = config.get(SETTINGS.wrapPresets, [...WRAP_PRESET_IDS]); + + const allowed = new Set(WRAP_PRESET_IDS); + return configured.filter((item): item is WrapPresetId => allowed.has(item)); +} + +export class StacWrapCodeActionProvider implements vscode.CodeActionProvider { + provideCodeActions( + document: vscode.TextDocument, + range: vscode.Range, + ): vscode.CodeAction[] { + if (document.languageId !== 'dart') { + return []; + } + + const config = vscode.workspace.getConfiguration(); + const enabled = config.get(SETTINGS.enableWrapQuickFix, true); + if (!enabled) { + return []; + } + + const target = findWrappableExpression(document, range); + if (!target) { + return []; + } + + const actions: vscode.CodeAction[] = []; + for (const presetId of getEnabledPresetIds()) { + if (target.widgetName === presetId) { + continue; + } + + const template = getPresetWrapper(presetId); + if (!template) { + continue; + } + + const action = new vscode.CodeAction(template.title, vscode.CodeActionKind.QuickFix); + action.command = { + command: WRAP_COMMANDS[presetId], + title: template.title, + arguments: [document.uri, target.range], + }; + actions.push(action); + } + + const customAction = new vscode.CodeAction( + 'Wrap with Stac widget', + vscode.CodeActionKind.QuickFix, + ); + customAction.command = { + command: COMMANDS.wrapWithStacWidget, + title: 'Wrap with Stac widget', + arguments: [document.uri, target.range], + }; + actions.push(customAction); + + + + const childExpression = findChildExpression(target.expression); + if (childExpression) { + const removeAction = new vscode.CodeAction( + 'Remove this Stac Widget', + vscode.CodeActionKind.QuickFix, + ); + removeAction.command = { + command: COMMANDS.removeStacWidget, + title: 'Remove this Stac Widget', + arguments: [document.uri, target.range], + }; + actions.push(removeAction); + } + + return actions; + } +} diff --git a/tools/stac-vscode/src/wrap/wrapperTemplates.ts b/tools/stac-vscode/src/wrap/wrapperTemplates.ts new file mode 100644 index 000000000..49422c6e2 --- /dev/null +++ b/tools/stac-vscode/src/wrap/wrapperTemplates.ts @@ -0,0 +1,89 @@ +import type { WidgetCatalogEntry } from '../generated/widgetCatalog'; + +export type ChildMode = 'child' | 'children'; + +export interface WrapperTemplate { + wrapperName: string; + title: string; + childMode: ChildMode; + beforeChildArgs: string[]; +} + +export const PRESET_WRAPPERS: ReadonlyArray = [ + { + wrapperName: 'StacContainer', + title: 'Wrap with StacContainer', + childMode: 'child', + beforeChildArgs: [], + }, + { + wrapperName: 'StacPadding', + title: 'Wrap with StacPadding', + childMode: 'child', + beforeChildArgs: ['padding: StacEdgeInsets.all(8)'], + }, + { + wrapperName: 'StacCenter', + title: 'Wrap with StacCenter', + childMode: 'child', + beforeChildArgs: [], + }, + { + wrapperName: 'StacAlign', + title: 'Wrap with StacAlign', + childMode: 'child', + beforeChildArgs: ['alignment: StacAlignmentDirectional.center'], + }, + { + wrapperName: 'StacSizedBox', + title: 'Wrap with StacSizedBox', + childMode: 'child', + beforeChildArgs: [], + }, + { + wrapperName: 'StacExpanded', + title: 'Wrap with StacExpanded', + childMode: 'child', + beforeChildArgs: [], + }, +]; + +export const PRESET_WRAPPER_NAMES = PRESET_WRAPPERS.map( + (template) => template.wrapperName, +); + +/** Placeholder template for "Wrap with Stac widget" — no pop-up; user types the class name inline. */ +export const CUSTOM_WIDGET_PLACEHOLDER_TEMPLATE: WrapperTemplate = { + wrapperName: 'StacWidget', + title: 'Wrap with Stac widget', + childMode: 'child', + beforeChildArgs: [], +}; + +export function templateFromWidgetCatalog( + widget: WidgetCatalogEntry, +): WrapperTemplate | undefined { + if (widget.supportsChild) { + return { + wrapperName: widget.className, + title: `Wrap with ${widget.className}`, + childMode: 'child', + beforeChildArgs: [], + }; + } + + if (widget.supportsChildren) { + return { + wrapperName: widget.className, + title: `Wrap with ${widget.className}`, + childMode: 'children', + beforeChildArgs: [], + }; + } + + return undefined; +} + +export function getPresetWrapper(wrapperName: string): WrapperTemplate | undefined { + return PRESET_WRAPPERS.find((wrapper) => wrapper.wrapperName === wrapperName); +} diff --git a/tools/stac-vscode/tsconfig.json b/tools/stac-vscode/tsconfig.json new file mode 100644 index 000000000..b8aa707f9 --- /dev/null +++ b/tools/stac-vscode/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "Node16", + "target": "ES2022", + "outDir": "out", + "lib": [ + "ES2022" + ], + "sourceMap": true, + "rootDir": "src", + "strict": true, /* enable all strict type-checking options */ + /* Additional Checks */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + } +} \ No newline at end of file diff --git a/tools/stac-vscode/vsc-extension-quickstart.md b/tools/stac-vscode/vsc-extension-quickstart.md new file mode 100644 index 000000000..c91206443 --- /dev/null +++ b/tools/stac-vscode/vsc-extension-quickstart.md @@ -0,0 +1,44 @@ +# Welcome to your VS Code Extension + +## What's in the folder + +* This folder contains all of the files necessary for your extension. +* `package.json` - this is the manifest file in which you declare your extension and command. + * The sample plugin registers a command and defines its title and command name. With this information VS Code can show the command in the command palette. It doesn’t yet need to load the plugin. +* `src/extension.ts` - this is the main file where you will provide the implementation of your command. + * The file exports one function, `activate`, which is called the very first time your extension is activated (in this case by executing the command). Inside the `activate` function we call `registerCommand`. + * We pass the function containing the implementation of the command as the second parameter to `registerCommand`. + +## Get up and running straight away + +* Press `F5` to open a new window with your extension loaded. +* Run your command from the command palette by pressing (`Ctrl+Shift+P` or `Cmd+Shift+P` on Mac) and typing `Hello World`. +* Set breakpoints in your code inside `src/extension.ts` to debug your extension. +* Find output from your extension in the debug console. + +## Make changes + +* You can relaunch the extension from the debug toolbar after changing code in `src/extension.ts`. +* You can also reload (`Ctrl+R` or `Cmd+R` on Mac) the VS Code window with your extension to load your changes. + +## Explore the API + +* You can open the full set of our API when you open the file `node_modules/@types/vscode/index.d.ts`. + +## Run tests + +* Install the [Extension Test Runner](https://marketplace.visualstudio.com/items?itemName=ms-vscode.extension-test-runner) +* Run the "watch" task via the **Tasks: Run Task** command. Make sure this is running, or tests might not be discovered. +* Open the Testing view from the activity bar and click the Run Test" button, or use the hotkey `Ctrl/Cmd + ; A` +* See the output of the test result in the Test Results view. +* Make changes to `src/test/extension.test.ts` or create new test files inside the `test` folder. + * The provided test runner will only consider files matching the name pattern `**.test.ts`. + * You can create folders inside the `test` folder to structure your tests any way you want. + +## Go further + +* [Follow UX guidelines](https://code.visualstudio.com/api/ux-guidelines/overview) to create extensions that seamlessly integrate with VS Code's native interface and patterns. +* Reduce the extension size and improve the startup time by [bundling your extension](https://code.visualstudio.com/api/working-with-extensions/bundling-extension). +* [Publish your extension](https://code.visualstudio.com/api/working-with-extensions/publishing-extension) on the VS Code extension marketplace. +* Automate builds by setting up [Continuous Integration](https://code.visualstudio.com/api/working-with-extensions/continuous-integration). +* Integrate to the [report issue](https://code.visualstudio.com/api/get-started/wrapping-up#issue-reporting) flow to get issue and feature requests reported by users.