diff --git a/.env.testing.slic b/.env.testing.slic index db7a2a7..64cc5ce 100644 --- a/.env.testing.slic +++ b/.env.testing.slic @@ -3,6 +3,11 @@ SLIC_PHP_VERSION=8.3 ENVIRONMENT=tests TEST_LOG_CHANNEL=stack TEST_LOG_LEVEL=debug +TEST_COMMAND_PREFIX=nxtest + +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_TEST_DATABASE=15 WP_VERSION=latest WP_ROOT_FOLDER=/var/www/html diff --git a/.gitattributes b/.gitattributes index dfb08e8..ba56558 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,6 +6,8 @@ /.gitignore export-ignore /.github export-ignore /docs export-ignore +/dev export-ignore +/src/Docs export-ignore /.env.testing.slic export-ignore /.env.slic.local export-ignore /.env.slic.run export-ignore diff --git a/.github/bin/repo-map.sh b/.github/bin/repo-map.sh index a4e10ec..b8c9af8 100755 --- a/.github/bin/repo-map.sh +++ b/.github/bin/repo-map.sh @@ -28,10 +28,10 @@ else fi # Use jq to generate the JSON array directly without line breaks -packages_json=$(find "$root/src" -name composer.json -print0 | +packages_json=$(find "$root/src" -mindepth 2 -maxdepth 2 \( -name composer.json -o -name package.json \) -print0 | while IFS= read -r -d $'\0' file; do # Extract the package name and directory - package_name=$(jq -r '.name' < "$file" | sed 's/stellarwp\///') + package_name=$(jq -r '.name' < "$file" | sed -E 's/^@?stellarwp\///') relative_directory=$(realpath --relative-to="$root/src" "$(dirname "$file")") # Build the JSON object for each package diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml new file mode 100644 index 0000000..d45e583 --- /dev/null +++ b/.github/workflows/docs-preview.yml @@ -0,0 +1,94 @@ +name: Documentation Preview + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - 'src/Docs/**' + - '.github/workflows/docs-preview.yml' + +concurrency: + group: docs-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + preview: + runs-on: ubuntu-latest + + permissions: + contents: read + deployments: write + pull-requests: write + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version-file: 'src/Docs/.nvmrc' + cache: npm + cache-dependency-path: 'src/Docs/package-lock.json' + + - name: Install dependencies + working-directory: src/Docs + run: npm ci + + - name: Build documentation + working-directory: src/Docs + run: npm run build + + - name: Deploy preview + id: deploy + if: github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' + uses: cloudflare/wrangler-action@v4 + with: + apiToken: ${{ secrets.CLOUDFLARE_DEPLOY_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_DEPLOY_ACCOUNT_ID }} + gitHubToken: ${{ secrets.GITHUB_TOKEN }} + packageManager: npm + workingDirectory: src/Docs + command: pages deploy dist --project-name=foundation-docs --branch=pr-${{ github.event.pull_request.number }} + + - name: Comment preview URL + if: github.event.pull_request.head.repo.full_name == github.repository && github.actor != 'dependabot[bot]' + uses: actions/github-script@v9 + env: + PREVIEW_URL: ${{ steps.deploy.outputs.pages-deployment-alias-url || steps.deploy.outputs.deployment-url }} + PREVIEW_SHA: ${{ github.event.pull_request.head.sha }} + with: + script: | + const marker = ''; + const body = [ + marker, + '## Documentation preview', + '', + `[Review this documentation update](${process.env.PREVIEW_URL})`, + '', + `Updated for commit \`${process.env.PREVIEW_SHA.slice(0, 7)}\`.`, + ].join('\n'); + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + const existing = comments.find((comment) => + comment.user?.login === 'github-actions[bot]' && comment.body?.includes(marker) + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 2861477..8459de8 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -19,6 +19,7 @@ jobs: with: php-version: '8.3' coverage: pcov + extensions: redis - name: Detect File Changes uses: dorny/paths-filter@v4 @@ -36,8 +37,7 @@ jobs: - name: Run code style checks if: ${{ steps.filter.outputs.php == 'true' }} - run: composer lint ${{ steps.filter.outputs.php_files }} + run: composer lint - name: Run static analysis run: composer analyze - diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8d67e88..e5fc22d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,6 +24,7 @@ jobs: with: php-version: ${{ matrix.php }} coverage: none + extensions: redis - name: Install Composer dependencies uses: ramsey/composer-install@v4 @@ -106,29 +107,78 @@ jobs: run: | "${SLIC_BIN}" run feature --ext DotReporter + - name: Run integration tests + if: github.event_name != 'pull_request' + run: | + "${SLIC_BIN}" run integration --ext DotReporter + + - name: Run Redis tests + if: github.event_name != 'pull_request' + run: | + "${SLIC_BIN}" run redis --ext DotReporter + - name: Run wpunit tests if: github.event_name != 'pull_request' run: | "${SLIC_BIN}" run wpunit --ext DotReporter - - name: Enable PCOV for coverage - if: github.event_name == 'pull_request' + - name: Run wpcli tests + if: github.event_name != 'pull_request' run: | - "${SLIC_BIN}" pcov on + "${SLIC_BIN}" run wpcli --ext DotReporter - name: Run Codeception tests with coverage if: github.event_name == 'pull_request' run: | - "${SLIC_BIN}" run --coverage --coverage-xml clover.xml --disable-coverage-php --ext DotReporter + composer run coverage:phpcov-install + composer run coverage:prepare + "${SLIC_BIN}" pcov on --yes + "${SLIC_BIN}" run unit --coverage coverage/unit.cov --ext DotReporter + "${SLIC_BIN}" run feature --coverage coverage/feature.cov --ext DotReporter + "${SLIC_BIN}" run redis --coverage coverage/redis.cov --ext DotReporter + "${SLIC_BIN}" run integration --coverage coverage/integration.cov --ext DotReporter + "${SLIC_BIN}" run wpunit --coverage coverage/wpunit.cov --ext DotReporter + "${SLIC_BIN}" run wpcli --coverage coverage/wpcli.cov --ext DotReporter + "${SLIC_BIN}" composer run coverage:merge + + - name: Test minimum supported Predis version + run: | + "${SLIC_BIN}" composer update predis/predis:3.0.0 --with-all-dependencies + "${SLIC_BIN}" run redis --ext DotReporter - - name: Monitor coverage + - name: Upload coverage report if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: coverage-report + path: clover.xml + if-no-files-found: error + retention-days: 1 + + coverage: + if: github.event_name == 'pull_request' + needs: slic + runs-on: ubuntu-latest + name: Monitor coverage + + permissions: + contents: read + pull-requests: write + statuses: write + + steps: + - name: Download coverage report + uses: actions/download-artifact@v8 + with: + name: coverage-report + + - name: Monitor coverage uses: slavcodev/coverage-monitor-action@v1 with: github_token: ${{ secrets.GITHUB_TOKEN }} comment_footer: false coverage_format: clover - coverage_path: tests/_output/clover.xml + coverage_path: clover.xml threshold_alert: 90 threshold_warning: 95 threshold_metric: "lines" diff --git a/.gitignore b/.gitignore index 0229a22..7cb9e19 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ vendor /coverage/ /tests/_output/* !/tests/_output/.gitkeep -/tests/CodeceptionSupport/ +!/tests/_output/coverage/ +!/tests/_output/coverage/.gitignore /tests/_data/temp/* !/tests/_data/temp/.gitkeep diff --git a/AGENTS.md b/AGENTS.md index b07274f..5221adc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,13 +4,20 @@ Foundation is a StellarWP Composer monorepo for reusable PHP packages intended for libraries and WordPress plugin ecosystems. -Initial packages: +Split packages: - `stellarwp/foundation-container` - `stellarwp/foundation-log` +- `stellarwp/foundation-lock` +- `stellarwp/foundation-lock-redis` +- `stellarwp/foundation-database` +- `stellarwp/foundation-identifier` - `stellarwp/foundation-pipeline` +- `stellarwp/foundation-shutdown` +- `stellarwp/foundation-view` - `stellarwp/foundation-wpcli` - `stellarwp/foundation-cli` +- `stellarwp/foundation-docs` ## Namespaces @@ -43,7 +50,19 @@ Feature-local interfaces should live in a `Contracts/` folder inside the feature Shared infrastructure interfaces should live under that shared namespace's `Contracts/` folder, for example `Process/Contracts/ProcessRunner.php`. -Generator commands should be grouped by the `make:*` workflow under `src/Cli/Commands/Make/`, for example `src/Cli/Commands/Make/WPCliCommand.php`. Shared generation infrastructure that is not itself a console command should live under `src/Cli/Generation/`. +Design public contracts as the smallest stable capabilities consumers need so applications can replace the supplied implementation without inheriting unrelated backend assumptions. Do not put filesystem, database, transport, framework, or other implementation-specific behavior on a general contract merely because the default concrete class supports it. Use a separate capability contract when only some implementations provide optional behavior, and bind each supported contract to the default implementation in the package provider. Follow interface segregation and dependency inversion: application code should be able to supply a substantially different implementation without implementing meaningless methods or extending Foundation internals. + +Keep convenience methods and implementation machinery on the concrete class unless they form a genuine reusable capability. Do not expose private helpers as public API speculatively. Prefer composition, and extract a focused collaborator when another real implementation needs to share the same policy or behavior. + +Avoid `use ... as ...` import aliases unless they resolve a real class-name collision or ambiguity. Prefer importing the class by its actual short name. The standing exception is `use lucatume\DI52\Container as C;`, which may be used for concise container factory callbacks. + +Exceptions should live in an `Exceptions/` folder. Put shared package exceptions at the package root, for example `src/Database/Exceptions/DatabaseException.php`; put feature-only exceptions under that feature's `Exceptions/` folder only when they are not shared outside that feature. + +Add `@throws` PHPDoc annotations to methods and constructors for exceptions they intentionally throw or propagate as part of their contract. Keep the annotation specific enough that callers can understand validation, infrastructure, and failure behavior without reading the implementation. + +Generator commands should be grouped by the `make:*` workflow under `src/Cli/Commands/Make/`, for example `src/Cli/Commands/Make/WPCliCommand.php`. When a make feature grows beyond a single command class or needs private collaborators, group that feature under its own namespace such as `src/Cli/Commands/Make/Database/`. Command-specific collaborators should live inside that feature namespace, not beside unrelated command classes in `Commands/Make/`. + +Shared generation infrastructure that is not itself a console command and is reused across command features should live under `src/Cli/Generation/`. Default stubs should live with the package that owns the generated class shape. For example, WP-CLI command stubs live in `src/WPCli/stubs/` because the WPCli package owns the base `Command` API. The CLI package owns resolving, rendering, and writing generated files. @@ -55,6 +74,8 @@ Generators that write references to Foundation classes should detect `extra.stra Generator stubs should use context-aware placeholders for PHP literals, such as `{{ description_php }}` instead of raw `{{ description }}` inside quoted PHP strings. +Migration generators must not offer a force-overwrite option. Existing migrations are identity-bearing history: edit a migration only before it has been applied anywhere, or create a new migration for a new schema change. + ## CLI Tooling Boundary `stellarwp/foundation-cli` is developer tooling and should normally be installed by split-package consumers with `composer require --dev stellarwp/foundation-cli`. It should not be packaged into production WordPress plugin zips when installed as a split package. @@ -65,6 +86,18 @@ Do not instruct consuming WordPress plugins to register `StellarWP\Foundation\Cl When generated code depends on runtime APIs, require the runtime package normally. For WP-CLI commands, install `stellarwp/foundation-wpcli` in `require` if the plugin ships those commands, and install `stellarwp/foundation-cli` in `require-dev` only for generation. +Do not register WP-CLI command classes directly with `$this->container->bind(CommandClass::class)` or `$this->container->singleton(CommandClass::class)` from providers loaded during normal WordPress bootstrap. DI52 creates the binding lazily, but its builder factory immediately calls `class_exists()` for string implementations. That autoloads the command class and its `WP_CLI_Command` parent before WP-CLI is available. Keep any contextual bindings for the command, then contribute it lazily through `WPCliProvider::COMMANDS` without separately binding it: + +```php +$this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ + $c->get(CommandClass::class), +]); +``` + +The command class will then be autowired only when `WPCliProvider` resolves the command collection during `cli_init`. + +`WPCliProvider` owns the configured `StellarWP\Foundation\WPCli\ValueObjects\CommandPrefix` singleton. Commands with explicit constructors should accept that object and pass it to the base `Command` constructor. Feature providers should not repeat contextual `$commandPrefix` bindings or define their own command-prefix container entries. + If local scaffolding assets such as `foundation/stubs/` should not be included in a consuming project's release archive, add them to that project's `.gitattributes` production zip exclusions. ## Container Providers @@ -73,10 +106,26 @@ When writing providers or container registration code, prefer container-driven c Use contextual bindings with `$this->container->when()->needs()->give()` for scalar constructor arguments, command lists, or feature-specific substitutions. Use a factory closure only when the value must be computed or resolved from the container, and keep that closure focused on supplying the constructor dependency rather than constructing the full object. +Foundation package providers must define their internal container identifiers, including scalar bindings and additive collection contribution points, with `self::class . '.descriptive_suffix'` instead of global literal strings. This allows namespace prefixing tools such as Strauss to scope the identifier to the prefixed provider class. Consuming applications should instead use stable text identifiers prefixed with their application or plugin name, for example `your-plugin.report.exporters`. Do not apply class-derived identifiers to configuration keys, persistent database or cache identifiers, lock names, channel names, WP-CLI command names, or other externally visible values that must remain stable across builds. + +Classes should take the dependencies they need directly. Do not make constructor dependencies nullable just to instantiate fallback concrete classes internally, for example `?Dependency $dependency = null` with `$this->dependency = $dependency ?? new Dependency()`. Register default implementations and aliases in a provider instead so consumers can replace them through container configuration. + +Use the optional `foundation.prefix` configuration key when Foundation-managed resources must be scoped to a consuming application. Its effective zero-configuration value is `nx`; providers should derive their default resource names from that shared value instead of repeating their own fallbacks. Distributable plugins must configure a stable, unique prefix so separate Foundation consumers do not share resources. Documentation and examples should use a generic lowercase kebab-case value such as `your-plugin`, never a developer-specific project name. Package-specific settings must take priority over values derived from the shared prefix. + +Classes should receive service collaborators through constructor injection. Direct `new` expressions inside application classes are reserved for immutable value or result objects, exceptions, PHP standard-library objects, and objects deliberately produced by an owning builder or factory. Keep feature-local value objects under that feature's `ValueObjects/` namespace. Value objects should be `final readonly` where possible and must not resolve or construct service dependencies. + +Organize provider registration by feature or capability, not by container mechanism. The main `register()` method should call focused private methods such as `registerConfiguration()`, `registerMigrations()`, `registerLocks()`, or `registerCliCommands()`. Keep each feature's contextual bindings beside the classes they configure. Avoid generic methods such as `configureContextualBindings()` that group unrelated bindings only because they use the same container API. + +Treat configured pipelines as provider-owned services. Bind each distinct pipe sequence under a feature-local container identifier, then use a contextual binding to give each consumer the pipeline it needs. Consumers should send values and choose the destination without assembling their own pipe lists. Use `bind()` rather than `singleton()` because `Pipeline` carries mutable execution state. + +Register infrastructure providers and top-level feature providers from the application's composition root, such as the ordered provider list in `App.php`. A provider that registers definitions, configuration, or hooks must not also register other providers. The exception is a feature composition provider whose sole responsibility is registering that feature's internal providers; it should contain no service bindings, configuration, hooks, or other behavior. Keep cross-feature and application-level dependencies visible in the `App.php` provider list. + ## Split Packages Split packages live in `src//` and are split to read-only repositories named `stellarwp/foundation-`. +`stellarwp/foundation-database` is a WordPress-backed database package. Keep its runtime implementation centered on `wpdb`, `dbDelta()`, WordPress table prefixes, and WP-CLI integration. If the project later needs file storage, Redis storage, PDO database support, or another non-WordPress backend, prefer a separate package or explicit driver package instead of making `foundation-database` a generic DBAL-style abstraction. + When adding a new split package, set its package `composer.json` PHP constraint to `>=8.3` unless the user explicitly says otherwise. PHP 7.4 release compatibility will be handled later by an automated Rector downgrade workflow, not by lowering the package PHP constraint during development. When adding external dependencies for split packages, choose version constraints whose package line supports PHP 7.4. Use `>=` constraints for those dependencies instead of caret constraints when preserving the PHP 7.4-compatible floor matters. For example, use a Symfony component version such as `>=5.4` rather than a newer line that requires PHP 8+. @@ -93,6 +142,8 @@ Each split package should include: - `.gitignore` - `.github/workflows/close-pull-request.yml` +Non-Composer split projects may use their ecosystem manifest instead of `composer.json`. For example, `src/Docs/` uses `package.json` and must remain discoverable by `.github/bin/repo-map.sh` so it splits to `stellarwp/foundation-docs`. All other required split-repository files and warning text still apply. + When adding a new split package, add its `stellarwp/foundation-` repository link to the root `README.md` repositories list. Each split package `README.md` must include this warning immediately after the package heading: @@ -143,6 +194,36 @@ After adding or changing split package dependencies, run `composer monorepo merg Use `composer monorepo list` to inspect available Monorepo Builder commands. +## Documentation + +The documentation site lives in `src/Docs/` and uses Astro with Starlight. Use the Node version in `src/Docs/.nvmrc`, install dependencies with `npm ci`, and run `npm run build` from `src/Docs/` after documentation changes. + +Same-repository documentation pull requests are previewed from the monorepo workflow. Production documentation is deployed only for published stable releases by the tag workflow that is split into `stellarwp/foundation-docs`; never deploy documentation production from a push to the monorepo's `main` branch. Both workflows use the `foundation-docs` Cloudflare Pages project through Direct Upload, configured with `production` as its production branch. + +Write public documentation as current product documentation. Do not mention implementation phases, review checkpoints, future documentation work, or temporary plans. If code behavior, public APIs, package requirements, configuration, or supported integrations change, update the relevant documentation in the same change. Add a component guide and sidebar entry when adding a public split package. + +Once a component has a central documentation guide, keep its split-package `README.md` focused on a short overview, installation, and links to the canonical guide. Do not duplicate full configuration and usage documentation across the README and documentation site. + +Structure component guides for scanning. Prefer a small set of root sections such as `Installation`, `Configuration`, `Usage`, and `Testing`, with task-oriented subsections beneath them. Avoid a long flat list of root headings. Lead with the decision a developer must make, then show installation, configuration, the simplest complete use case, important failure behavior, and testing. + +When one component exposes several independently used capabilities, use a concise overview page with nested task guides instead of forcing every capability into one long page. Keep shared installation and configuration on the overview, then give each task guide one clear ownership boundary. Link to canonical shared behavior rather than duplicating it across component pages. + +Place operational warnings beside the decision or API behavior they qualify. State the concrete failure mode, distinguish expected outcomes from infrastructure failures, tell the developer whether to skip, retry, or abort, and include compact pseudocode when the response would otherwise remain ambiguous. + +Order sequential setup guides so files are created before later examples reference or call them. When a component example assumes the application composition root or provider architecture, link back to the relevant Start Here guides. Prefer Starlight link cards for these prerequisite guides and Starlight asides for decisions or warnings developers must not miss. + +Keep runtime and developer dependencies distinct in installation documentation. Standalone WordPress plugins should require only the split runtime packages they ship and install `stellarwp/foundation-cli` with `--dev`. Before showing `composer require stellarwp/foundation`, warn that the aggregate package includes the developer CLI in its normal installation and that `--no-dev` will not remove it. + +Explain `foundation.prefix` according to the deployment boundary. A complete WordPress application that centrally owns its themes, plugins, and Foundation composition root can use the shared `nx` default. A distributable standalone plugin must configure a stable, unique prefix because PHP namespace prefixing does not isolate shared WP-CLI command names, database tables, or locks. + +Documentation examples for consuming WordPress projects should use Snake_Case class names and WordPress formatting, including a blank line immediately after each class declaration's opening brace. Keep translatable user-facing text in the class that renders it; configuration examples should represent runtime or deployment behavior rather than untranslated display copy. Validate required scalar configuration at construction boundaries when an empty value would make the feature invalid. + +WP-CLI command class examples should include one or more `@example` annotations showing the complete `wp ` invocation, including a representative invocation with options or flags when applicable. + +Use the canonical application architecture in WordPress examples: `App` owns the request singleton, providers are registered in explicit dependency order, and feature providers group definitions and hooks by capability. Use `$this->container->callback(ClassName::class, 'method')` for WordPress callbacks when the container should resolve the service lazily. + +Keep full source paths in the prose immediately before code examples and use only the filename in a code-block `title`. Nova's Shiki metadata parser interprets path segments such as `/Lock/` as word-highlighting instructions and otherwise adds unintended borders around matching code tokens. + ## Verification When `composer lint` reports style-only issues, run `composer format` to let the project formatter fix them before making manual formatting edits. @@ -151,13 +232,17 @@ Reusable test fixtures, sample classes, and test doubles should live under `test Tests that need writable temporary files or directories should use a test-specific subdirectory under `tests/_data/temp` instead of `sys_get_temp_dir()`. Use `$this->temp_dir('')` when only the path is needed; it mirrors `codecept_data_dir()` and does not create the directory. Use `$this->prepare_temp_dir('')` in `setUp()` to create a unique clean directory under that name and register it for automatic cleanup by the base test case. Only call `$this->remove_temp_dir('')` manually when a test needs to remove the prepared directories before teardown. -Codeception tests run through SLIC. Use `.env.testing.slic` as the SLIC/Codeception environment file. First-time local setup is `slic here` from the directory that contains this repository, `slic use foundation` from the repository, `slic composer install`, and `slic cc build`. If host-installed dependencies conflict with the SLIC PHP version, run `slic composer update --with-all-dependencies` inside the container. Run suites with `slic run unit`, `slic run feature`, and `composer test:wpunit` or `slic run wpunit`. +Codeception tests run through SLIC. Use SLIC 2.3.0 or newer so PCOV-backed coverage commands are available. Use `.env.testing.slic` as the SLIC/Codeception environment file. First-time local setup is `slic here` from the directory that contains this repository, `slic use foundation` from the repository, `slic composer install`, and `slic cc build`. If host-installed dependencies conflict with the SLIC PHP version, run `slic composer update --with-all-dependencies` inside the container. Run suites with `slic run unit`, `slic run feature`, `composer test:redis` or `slic run redis`, `composer test:integration` or `slic run integration`, `composer test:wpunit` or `slic run wpunit`, and `composer test:wpcli` or `slic run wpcli`. + +GitHub workflows should check out SLIC from `main`; do not pin SLIC to a release tag or commit. + +Test suite meanings: `Unit` is isolated class/package behavior, `Feature` is Foundation feature behavior without bootstrapping WordPress, `redis` is real Redis behavior shared across packages and run against SLIC's Redis service, `integration` is multi-provider/container behavior that may require WordPress runtime APIs such as hooks, `wpdb`, `dbDelta()`, or globals, `wpunit` is lower-level WordPress-loaded behavior through wp-browser, and `wpcli` is the shared monorepo suite for testing WP-CLI commands through wp-browser's WPCLI module. If a PHPUnit test uses `#[DataProvider]` and must run under Codeception, also include the matching `@dataProvider` docblock because Codeception's PHPUnit loader reads docblock providers for these tests. -Test suite meanings: `Unit` is isolated class/package behavior, `Feature` is Foundation feature behavior without bootstrapping WordPress, and `wpunit` is WordPress-loaded behavior through wp-browser. If a PHPUnit test uses `#[DataProvider]` and must run under Codeception, also include the matching `@dataProvider` docblock because Codeception's PHPUnit loader reads docblock providers for these tests. +Use `integration` for behavior where multiple providers/packages must be registered together to prove the container graph works. Use `wpunit` for a single package/class where the main concern is direct WordPress API behavior. Use `wpcli` for real WP-CLI command execution shared across packages. Keep unit tests focused on portable package behavior and pure collaborators; do not build large fake WordPress runtimes in unit tests when the behavior can be covered with wp-browser. Use `tests/WPUnitSupport/WPTestCase.php` as the base class for wpunit tests instead of extending Codeception's `WPTestCase` directly. Keep Codeception-generated actor files in `tests/CodeceptionSupport/`; that directory is ignored and excluded from lint/static analysis. -After completing a feature, run `composer test:coverage`, review `clover.xml` for missed source coverage, and add meaningful tests for uncovered behavior before considering the feature complete. +After completing a feature, run `composer test:coverage`, review `clover.xml` for missed source coverage, and add meaningful tests for uncovered behavior before considering the feature complete. Coverage uses SLIC 2.3.0+ PCOV support, runs each SLIC suite separately, and merges the serialized `.cov` artifacts with `phpcov`; run the merge through `slic composer run coverage:merge` or `slic composer run coverage:merge-html` because the coverage files contain container paths like `/var/www/html/wp-content/plugins/foundation`. ## Releases @@ -165,6 +250,7 @@ After completing a feature, run `composer test:coverage`, review `clover.xml` fo - Run `composer monorepo bump-interdependency ` when planning a major version release so Foundation packages that depend on each other require the new major line, for example `^3.0`. It may also be useful for a minor release when one package must require APIs added in that new minor, for example `^1.1`. - Before publishing a release, verify the intended release-line package constraints are already committed. For a minor release such as `1.2.0`, internal Foundation package dependencies should already require the released line, for example `^1.2`. - Publishing a GitHub release creates the release tag and triggers the tagged monorepo split. Wait for the tagged `Split Monorepo Packages and Release` workflow to succeed before considering the release complete. +- When a release includes `foundation-docs`, also verify that the tag-triggered `Deploy Documentation` workflow succeeds in the `stellarwp/foundation-docs` split repository before considering the documentation release complete. - After a successful tagged split for a minor or major `.0` release, the split workflow automatically bumps internal package constraints and branch aliases to the next development line on `main`, for example from `^1.2` and `1.2.x-dev` to `^1.3` and `1.3.x-dev`. - The post-release automation intentionally skips patch tags such as `1.2.1`, because patch releases should not move `dev-main` to a new minor development line. - If the post-release automation fails, fetch the release tag and manually run `composer monorepo bump-interdependency `, `composer monorepo package-alias`, and `composer monorepo merge`, then commit and push the updated package `composer.json` files. diff --git a/README.md b/README.md index 3a83a88..9a776b1 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,28 @@ # Foundation -Foundation is a StellarWP Composer monorepo for reusable PHP packages intended for libraries and WordPress plugin ecosystems. +Foundation is a StellarWP Composer monorepo of shared PHP infrastructure for Nexcess libraries and WordPress plugins. Its packages are publicly available, while Nexcess application needs primarily drive changes and the roadmap. > [!NOTE] > This monorepo splits each package out into their own sub-repository, if you only need a specific component you can install only that specific one. +See the [Foundation documentation](https://foundation.nexcess.dev/) for installation, application architecture, component configuration, and developer tooling. + ## Repositories -- [stellarwp/foundation-container](https://github.com/stellarwp/foundation-container) -- [stellarwp/foundation-pipeline](https://github.com/stellarwp/foundation-pipeline) -- [stellarwp/foundation-log](https://github.com/stellarwp/foundation-log) -- [stellarwp/foundation-wpcli](https://github.com/stellarwp/foundation-wpcli) -- [stellarwp/foundation-cli](https://github.com/stellarwp/foundation-cli) + +| Package | Use when | Installation | +| --- | --- | --- | +| [stellarwp/foundation-container](https://github.com/stellarwp/foundation-container) | The application needs Foundation's DI container and providers | Runtime | +| [stellarwp/foundation-pipeline](https://github.com/stellarwp/foundation-pipeline) | Work should pass through an ordered middleware-style pipeline | Runtime | +| [stellarwp/foundation-log](https://github.com/stellarwp/foundation-log) | Services need a configured PSR logger | Runtime | +| [stellarwp/foundation-shutdown](https://github.com/stellarwp/foundation-shutdown) | Deferred work should run during PHP shutdown, optionally after finishing the response | Runtime | +| [stellarwp/foundation-lock](https://github.com/stellarwp/foundation-lock) | Code needs the portable lock contract or process-local test implementation | Runtime | +| [stellarwp/foundation-lock-redis](https://github.com/stellarwp/foundation-lock-redis) | Multiple processes or servers coordinate through dedicated Redis | Runtime | +| [stellarwp/foundation-database](https://github.com/stellarwp/foundation-database) | A WordPress application needs queries, migrations, or database-backed locks | Runtime | +| [stellarwp/foundation-identifier](https://github.com/stellarwp/foundation-identifier) | Services need injectable ULID generation and validation | Runtime | +| [stellarwp/foundation-view](https://github.com/stellarwp/foundation-view) | Services need scoped PHP template rendering without global state | Runtime | +| [stellarwp/foundation-wpcli](https://github.com/stellarwp/foundation-wpcli) | A shipped WordPress plugin exposes WP-CLI commands | Runtime | +| [stellarwp/foundation-cli](https://github.com/stellarwp/foundation-cli) | Developers need Foundation generators or monorepo maintenance commands | Development | +| [stellarwp/foundation-docs](https://github.com/stellarwp/foundation-docs) | Contributors maintain or deploy the Foundation documentation site | Documentation | ## Installation @@ -52,7 +64,10 @@ Run the Codeception suites with SLIC: ```bash slic run unit slic run feature +composer test:redis +composer test:integration composer test:wpunit +composer test:wpcli ``` The first time you run the WordPress suite locally, point SLIC at the directory that contains this repository and select the `foundation` project: @@ -64,7 +79,10 @@ cd foundation slic use foundation slic composer install slic cc build +composer test:integration +composer test:redis composer test:wpunit +composer test:wpcli ``` If dependencies were installed on a different host PHP version and the SLIC container reports Composer platform conflicts, refresh them inside SLIC: @@ -75,14 +93,16 @@ slic composer update --with-all-dependencies Run `slic cc build` again after changing Codeception suite configuration or modules. Generated Codeception actor files are written to `tests/CodeceptionSupport/` and are intentionally ignored. -The `unit` and `feature` SLIC suites run the same tests as `composer test:unit` and `composer test:feature`. The `wpunit` suite runs WordPress-loaded tests through wp-browser. +The `unit` and `feature` SLIC suites run the same tests as `composer test:unit` and `composer test:feature`. The `redis` suite runs client interoperability and lease behavior against SLIC's Redis service. The `integration` suite covers multi-provider/container behavior that needs WordPress runtime APIs. The `wpunit` suite runs lower-level WordPress-loaded tests through wp-browser. The `wpcli` suite is shared across the monorepo for WP-CLI command tests and uses wp-browser's WPCLI module without the full wpunit module stack. -Generate the test coverage HTML dashboard (XDEBUG required to be enabled on your machine): +Generate the test coverage HTML dashboard: ```bash composer test:coverage-html ``` +Coverage uses SLIC 2.3.0+ PCOV support for faster collection. It runs each SLIC suite separately, writes serialized `.cov` artifacts, and merges them with `phpcov` so multiple WordPress-loaded suites can contribute to one Clover or HTML report. + ### Code Quality Check your code style: diff --git a/composer.json b/composer.json index 7595932..4cdb81d 100644 --- a/composer.json +++ b/composer.json @@ -13,30 +13,40 @@ "ext-curl": "*", "ext-exif": "*", "adbario/php-dot-notation": ">=2.5", + "arokettu/random-polyfill": ">=1.0.6 <1.99", "lucatume/di52": ">=4.1", "monolog/monolog": "^2.11", + "nikic/php-parser": ">=5.0 <6.0", "psr/log": ">=1.0", "stellarwp/container-contract": "^1.1", "symfony/console": ">=5.4", "vlucas/phpdotenv": ">=4.3" }, "require-dev": { + "ext-redis": "*", "lucatume/wp-browser": "^4.5", "monorepo-php/monorepo": "^12.7", "nunomaduro/collision": "^8.9", "php-mock/php-mock-mockery": "^1.5", - "php-stubs/wordpress-stubs": ">=6.0", + "php-stubs/wordpress-stubs": "^7.0", "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^2.2", "phpunit/phpunit": "^11.5", + "predis/predis": ">=3.0 <4.0", "wp-cli/wp-cli": ">=2.12", "zenphp/pinte": "^1.2" }, "replace": { "stellarwp/foundation-cli": "self.version", "stellarwp/foundation-container": "self.version", + "stellarwp/foundation-database": "self.version", + "stellarwp/foundation-identifier": "self.version", + "stellarwp/foundation-lock": "self.version", + "stellarwp/foundation-lock-redis": "self.version", "stellarwp/foundation-log": "self.version", "stellarwp/foundation-pipeline": "self.version", + "stellarwp/foundation-shutdown": "self.version", + "stellarwp/foundation-view": "self.version", "stellarwp/foundation-wpcli": "self.version" }, "minimum-stability": "dev", @@ -45,8 +55,14 @@ "psr-4": { "StellarWP\\Foundation\\Cli\\": "src/Cli/", "StellarWP\\Foundation\\Container\\": "src/Container/", + "StellarWP\\Foundation\\Database\\": "src/Database/", + "StellarWP\\Foundation\\Identifier\\": "src/Identifier/", + "StellarWP\\Foundation\\LockRedis\\": "src/LockRedis/", + "StellarWP\\Foundation\\Lock\\": "src/Lock/", "StellarWP\\Foundation\\Log\\": "src/Log/", "StellarWP\\Foundation\\Pipeline\\": "src/Pipeline/", + "StellarWP\\Foundation\\Shutdown\\": "src/Shutdown/", + "StellarWP\\Foundation\\View\\": "src/View/", "StellarWP\\Foundation\\WPCli\\": "src/WPCli/" }, "exclude-from-classmap": [ @@ -56,9 +72,17 @@ }, "autoload-dev": { "psr-4": { - "StellarWP\\Foundation\\Tests\\": "tests/", + "StellarWP\\Foundation\\Tests\\Feature\\": "tests/Feature/", + "StellarWP\\Foundation\\Tests\\Integration\\": "tests/integration/", + "StellarWP\\Foundation\\Tests\\Redis\\": "tests/redis/", + "StellarWP\\Foundation\\Tests\\Support\\": "tests/Support/", + "StellarWP\\Foundation\\Tests\\Unit\\": "tests/Unit/", + "StellarWP\\Foundation\\Tests\\WPUnitSupport\\": "tests/WPUnitSupport/", "StellarWP\\Foundation\\Tests\\WPUnit\\": "tests/wpunit/" - } + }, + "classmap": [ + "tests/TestCase.php" + ] }, "bin": [ "src/Cli/bin/foundation" @@ -80,9 +104,32 @@ "test:slic": "slic run", "test:slic:unit": "slic run unit", "test:slic:feature": "slic run feature", + "test:integration": "slic run integration", + "test:redis": "slic run redis", "test:wpunit": "slic run wpunit", - "test:coverage": "slic xdebug on && slic run --coverage --coverage-xml clover.xml --disable-coverage-php; status=$?; slic xdebug off; exit $status", - "test:coverage-html": "slic xdebug on && slic run --coverage --coverage-html coverage --disable-coverage-php; status=$?; slic xdebug off; exit $status", + "test:wpcli": "slic run wpcli", + "test:coverage": "@test:coverage:split", + "test:coverage-html": "@test:coverage-html:split", + "test:clean": "rm -f clover.xml tests/_output/coverage/*.cov && rm -rf ./coverage", + "coverage:phpcov-install": [ + "mkdir -p dev/bin", + "test -f ./dev/bin/phpcov.phar || curl -L -o ./dev/bin/phpcov.phar https://phar.phpunit.de/phpcov-10.0.1.phar" + ], + "coverage:prepare": "rm -f tests/_output/coverage/*.cov", + "coverage:merge": "@php dev/bin/phpcov.phar merge tests/_output/coverage --clover clover.xml", + "coverage:merge-html": "@php dev/bin/phpcov.phar merge tests/_output/coverage --clover clover.xml --html coverage", + "test:coverage:split": [ + "@coverage:phpcov-install", + "@coverage:prepare", + "slic pcov on --yes && slic run unit --coverage coverage/unit.cov && slic run feature --coverage coverage/feature.cov && slic run redis --coverage coverage/redis.cov && slic run integration --coverage coverage/integration.cov && slic run wpunit --coverage coverage/wpunit.cov && slic run wpcli --coverage coverage/wpcli.cov; rc=$?; slic pcov off; exit $rc", + "slic composer run coverage:merge" + ], + "test:coverage-html:split": [ + "@coverage:phpcov-install", + "@coverage:prepare", + "slic pcov on --yes && slic run unit --coverage coverage/unit.cov && slic run feature --coverage coverage/feature.cov && slic run redis --coverage coverage/redis.cov && slic run integration --coverage coverage/integration.cov && slic run wpunit --coverage coverage/wpunit.cov && slic run wpcli --coverage coverage/wpcli.cov; rc=$?; slic pcov off; exit $rc", + "slic composer run coverage:merge-html" + ], "analyze": "@php vendor/bin/phpstan analyse --ansi --memory-limit 2G", "lint": "@php vendor/bin/pinte --test -v", "format": "@php vendor/bin/pinte -v" @@ -96,9 +143,19 @@ "test:slic": "Run all Codeception suites through SLIC.", "test:slic:unit": "Run the Codeception unit suite through SLIC.", "test:slic:feature": "Run the Codeception feature suite through SLIC.", + "test:integration": "Run the WordPress-loaded integration suite through SLIC.", + "test:redis": "Run real Redis integration tests through SLIC.", "test:wpunit": "Run the WordPress-loaded wpunit suite through SLIC.", - "test:coverage": "Generate combined SLIC/Codeception Clover coverage, then turn Xdebug off.", - "test:coverage-html": "Generate combined SLIC/Codeception HTML coverage, then turn Xdebug off.", + "test:wpcli": "Run the shared WP-CLI command suite through SLIC.", + "test:coverage": "Generate merged Clover coverage from split SLIC suite artifacts.", + "test:coverage-html": "Generate merged Clover and HTML coverage from split SLIC suite artifacts.", + "test:clean": "Remove generated test coverage reports and serialized coverage artifacts.", + "coverage:phpcov-install": "Download the pinned phpcov PHAR used to merge split coverage artifacts.", + "coverage:prepare": "Create and clear the split coverage artifact directory.", + "coverage:merge": "Merge split coverage artifacts into clover.xml. Run through SLIC so container paths resolve.", + "coverage:merge-html": "Merge split coverage artifacts into clover.xml and HTML coverage. Run through SLIC so container paths resolve.", + "test:coverage:split": "Run SLIC suites separately, merge PHPUnit coverage artifacts, and generate clover.xml.", + "test:coverage-html:split": "Run SLIC suites separately, merge PHPUnit coverage artifacts, and generate clover.xml plus HTML coverage.", "analyze": "Run PHPStan static analysis.", "lint": "Check code style with Pinte.", "format": "Fix code style with Pinte." diff --git a/dev/bin/.gitignore b/dev/bin/.gitignore new file mode 100644 index 0000000..86d8465 --- /dev/null +++ b/dev/bin/.gitignore @@ -0,0 +1 @@ +*.phar diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 0d0bfe2..ec016cc 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -5,6 +5,7 @@ parameters: - tests scanFiles: - vendor/php-stubs/wordpress-stubs/wordpress-stubs.php + - vendor/wp-cli/wp-cli/php/utils.php excludePaths: analyse: - src/*/vendor/* diff --git a/src/Cli/.github/workflows/close-pull-request.yml b/src/Cli/.github/workflows/close-pull-request.yml index 6bfbabe..ddbc0e9 100644 --- a/src/Cli/.github/workflows/close-pull-request.yml +++ b/src/Cli/.github/workflows/close-pull-request.yml @@ -2,7 +2,10 @@ name: Close Pull Request on: pull_request_target: - types: [opened] + types: [opened, reopened] + +permissions: + pull-requests: write jobs: run: diff --git a/src/Cli/CliProvider.php b/src/Cli/CliProvider.php index f56297d..61533ca 100644 --- a/src/Cli/CliProvider.php +++ b/src/Cli/CliProvider.php @@ -3,6 +3,12 @@ namespace StellarWP\Foundation\Cli; use lucatume\DI52\Container; +use PhpParser\Lexer; +use PhpParser\ParserFactory; +use StellarWP\Foundation\Cli\Commands\Make\Database\MigrationCommand; +use StellarWP\Foundation\Cli\Commands\Make\Database\ProviderCommand; +use StellarWP\Foundation\Cli\Commands\Make\Database\ProviderRegistrationEditor; +use StellarWP\Foundation\Cli\Commands\Make\Database\TableCommand; use StellarWP\Foundation\Cli\Commands\Make\WPCliCommand; use StellarWP\Foundation\Cli\Commands\Package\Contracts\PackageRepositoryCreator; use StellarWP\Foundation\Cli\Commands\Package\CreateCommand; @@ -13,6 +19,7 @@ use StellarWP\Foundation\Cli\Commands\Package\PackageScaffolder; use StellarWP\Foundation\Cli\Generation\ComposerAutoloadResolver; use StellarWP\Foundation\Cli\Generation\GeneratedFileWriter; +use StellarWP\Foundation\Cli\Generation\Php\PhpSourceEditor; use StellarWP\Foundation\Cli\Generation\StubRenderer; use StellarWP\Foundation\Cli\Generation\StubResolver; use StellarWP\Foundation\Cli\Generation\WordPressClassNameResolver; @@ -28,7 +35,7 @@ */ final class CliProvider extends Provider { - public const string ROOT_PATH = 'foundation.cli.root_path'; + public const string ROOT_PATH = self::class . '.root_path'; public function register(): void { $this->container->singleton(self::ROOT_PATH, getcwd() ?: dirname(__DIR__, 2)); @@ -53,10 +60,25 @@ public function register(): void { ->needs('$rootPath') ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); + $this->container->when(MigrationCommand::class) + ->needs('$rootPath') + ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); + + $this->container->when(ProviderCommand::class) + ->needs('$rootPath') + ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); + + $this->container->when(TableCommand::class) + ->needs('$rootPath') + ->give(static fn (Container $c): string => $c->get(self::ROOT_PATH)); + $this->container->when(Application::class) ->needs('$commands') ->give(static fn (Container $c): array => [ $c->get(CreateCommand::class), + $c->get(MigrationCommand::class), + $c->get(ProviderCommand::class), + $c->get(TableCommand::class), $c->get(WPCliCommand::class), ]); @@ -71,8 +93,15 @@ public function register(): void { $this->container->singleton(WordPressClassNameResolver::class); $this->container->singleton(ComposerAutoloadResolver::class); $this->container->singleton(GeneratedFileWriter::class); + $this->container->singleton(Lexer::class); + $this->container->singleton(ParserFactory::class); + $this->container->singleton(PhpSourceEditor::class); $this->container->singleton(StubRenderer::class); $this->container->singleton(StubResolver::class); + $this->container->singleton(MigrationCommand::class); + $this->container->singleton(ProviderCommand::class); + $this->container->singleton(ProviderRegistrationEditor::class); + $this->container->singleton(TableCommand::class); $this->container->singleton(WPCliCommand::class); $this->container->singleton(Application::class); } diff --git a/src/Cli/Commands/Make/Database/MigrationCommand.php b/src/Cli/Commands/Make/Database/MigrationCommand.php new file mode 100644 index 0000000..a9872a3 --- /dev/null +++ b/src/Cli/Commands/Make/Database/MigrationCommand.php @@ -0,0 +1,371 @@ +setDescription('Generate a Foundation database migration class.') + ->addArgument('name', InputArgument::REQUIRED, 'Migration class name, e.g. Create_Reports_Table, Bump_Version, or create-reports-table.') + ->addOption('namespace', null, InputOption::VALUE_REQUIRED, 'Namespace for the generated migration class.') + ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Directory where the migration class should be written.') + ->addOption('provider', null, InputOption::VALUE_REQUIRED, 'Database provider file to update when it exists.') + ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Stable migration identifier: nonblank, unpadded, non-integer-like, and at most 191 bytes.') + ->addOption('table-class', null, InputOption::VALUE_REQUIRED, 'Table class or base name used by a table-backed migration.') + ->addOption('table-namespace', null, InputOption::VALUE_REQUIRED, 'Namespace containing the table class.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $this->validateExplicitProviderUpdate($input); + $file = $this->generatedFile($input); + + if (file_exists($file->path)) { + throw new RuntimeException(sprintf( + 'Migration already exists: %s. Edit it directly or create a new migration.', + $file->relativePath + )); + } + + $this->fileWriter->write($file); + $providerPath = $this->updateProvider($input, $output); + } catch (RuntimeException $exception) { + $output->writeln('' . $exception->getMessage() . ''); + + return Command::FAILURE; + } + + $output->writeln(sprintf('Created: %s', $file->relativePath)); + $output->writeln(''); + $output->writeln('Register this migration with DatabaseProvider::MIGRATIONS using mergeArrayVar().'); + + if ($providerPath !== null) { + $output->writeln(sprintf('Updated: %s', $this->relativePath($providerPath))); + } + + $runtimeDependencyWarning = $this->runtimeDependencyWarning(); + + if ($runtimeDependencyWarning !== null) { + $output->writeln(''); + $output->writeln('Runtime dependency missing: ' . $runtimeDependencyWarning); + } + + return Command::SUCCESS; + } + + private function generatedFile(InputInterface $input): GeneratedFile { + $className = $this->classNameResolver->className((string) $input->getArgument('name')); + $project = $this->autoloadResolver->project(); + $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); + $path = $this->path($input, $namespace, $project); + $relative = $this->relativePath($path . '/' . $className . '.php'); + $idOption = $input->getOption('id'); + $id = (new Id(is_string($idOption) ? $idOption : $this->classNameResolver->migrationId($className)))->value; + + if ($this->isTableMigration($input, $className)) { + $stub = $this->stubResolver->resolve('database', 'table-migration', DatabaseStubPath::tableMigration()); + $tableNamespace = $this->tableNamespace($input, $project->defaultPsr4Namespace()); + $tableClass = $this->tableClass($input, $className); + + return new GeneratedFile( + path: $path . '/' . $className . '.php', + relativePath: $relative, + contents: $this->stubRenderer->render($stub, [ + 'namespace' => $namespace, + 'class' => $className, + 'id_php' => $this->phpString($id), + 'table_class' => $tableClass, + 'table_namespace' => $tableNamespace, + 'foundation_database_migration' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Migration'), + 'foundation_database_schema' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Schema'), + ]) + ); + } + + $stub = $this->stubResolver->resolve('database', 'migration', DatabaseStubPath::migration()); + + return new GeneratedFile( + path: $path . '/' . $className . '.php', + relativePath: $relative, + contents: $this->stubRenderer->render($stub, [ + 'namespace' => $namespace, + 'class' => $className, + 'id_php' => $this->phpString($id), + 'foundation_database_migration' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Migration'), + 'foundation_database_schema' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Schema'), + 'foundation_database_irreversible_migration' => $project->foundationClass('StellarWP\\Foundation\\Database\\Exceptions\\IrreversibleMigration'), + ]) + ); + } + + private function validateExplicitProviderUpdate(InputInterface $input): void { + if (! $this->hasExplicitProvider($input)) { + return; + } + + $project = $this->autoloadResolver->project(); + $className = $this->classNameResolver->className((string) $input->getArgument('name')); + $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); + $providerPath = $this->providerPath($input, $project); + + if (! is_file($providerPath)) { + throw new RuntimeException(sprintf('Could not update database provider "%s": file does not exist.', $this->relativePath($providerPath))); + } + + $status = $this->providerUpdater->checkMigration($providerPath, $className, $namespace); + + if ($status === ProviderRegistrationEditor::UPDATED || $status === ProviderRegistrationEditor::ALREADY_REGISTERED) { + return; + } + + throw new RuntimeException(sprintf( + 'Could not update database provider "%s": %s.', + $this->relativePath($providerPath), + $this->providerUpdateFailure($status) + )); + } + + private function updateProvider(InputInterface $input, OutputInterface $output): ?string { + $project = $this->autoloadResolver->project(); + $className = $this->classNameResolver->className((string) $input->getArgument('name')); + $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); + $providerPath = $this->providerPath($input, $project); + $explicit = $this->hasExplicitProvider($input); + + if (! is_file($providerPath)) { + if ($explicit) { + throw new RuntimeException(sprintf('Could not update database provider "%s": file does not exist.', $this->relativePath($providerPath))); + } + + return null; + } + + $status = $this->providerUpdater->addMigration($providerPath, $className, $namespace); + + if ($status === ProviderRegistrationEditor::UPDATED) { + return $providerPath; + } + + if ($status === ProviderRegistrationEditor::ALREADY_REGISTERED) { + return null; + } + + if ($explicit) { + throw new RuntimeException(sprintf( + 'Could not update database provider "%s": %s.', + $this->relativePath($providerPath), + $this->providerUpdateFailure($status) + )); + } + + $output->writeln(sprintf( + 'Provider not updated: %s (%s). Register %s manually.', + $this->relativePath($providerPath), + $this->providerUpdateFailure($status), + $className + )); + + return null; + } + + private function isTableMigration(InputInterface $input, string $className): bool { + $tableClass = $input->getOption('table-class'); + + if (is_string($tableClass) && trim($tableClass) !== '') { + return true; + } + + return preg_match('/^Create_.*_Table$/', $className) === 1; + } + + private function tableClass(InputInterface $input, string $migrationClass): string { + $tableClass = $input->getOption('table-class'); + + if (is_string($tableClass) && trim($tableClass) !== '') { + return $this->classNameResolver->tableClass($tableClass); + } + + $name = (string) preg_replace('/^Create_?/', '', $migrationClass); + + return $this->classNameResolver->tableClass($name); + } + + private function phpString(string $value): string { + return var_export($value, true); + } + + private function namespace(InputInterface $input, Psr4Namespace $autoload): string { + $namespace = $input->getOption('namespace'); + + if (is_string($namespace) && trim($namespace) !== '') { + return $this->validNamespace(trim($namespace, '\\')); + } + + return trim($autoload->namespace, '\\') . '\\Database\\Migrations'; + } + + private function tableNamespace(InputInterface $input, Psr4Namespace $autoload): string { + $namespace = $input->getOption('table-namespace'); + + if (is_string($namespace) && trim($namespace) !== '') { + return $this->validNamespace(trim($namespace, '\\')); + } + + return trim($autoload->namespace, '\\') . '\\Database\\Tables'; + } + + private function path(InputInterface $input, string $namespace, ComposerProject $project): string { + $path = $input->getOption('path'); + + if (is_string($path) && trim($path) !== '') { + return $this->absolutePath($path); + } + + $autoload = $project->psr4NamespaceFor($namespace); + + if ($autoload === null) { + throw new RuntimeException(sprintf( + 'Namespace "%s" is outside the Composer PSR-4 namespaces in composer.json. Pass --path to choose an output directory.', + $namespace + )); + } + + return $this->rootPath . '/' . $autoload->pathFor($namespace); + } + + private function providerPath(InputInterface $input, ComposerProject $project): string { + $provider = $input->getOption('provider'); + + if (is_string($provider) && trim($provider) !== '') { + return $this->absolutePath($provider); + } + + $namespace = trim($project->defaultPsr4Namespace()->namespace, '\\') . '\\Database'; + $autoload = $project->psr4NamespaceFor($namespace); + + if ($autoload === null) { + return $this->rootPath . '/src/Database/Provider.php'; + } + + return $this->rootPath . '/' . $autoload->pathFor($namespace) . '/Provider.php'; + } + + private function hasExplicitProvider(InputInterface $input): bool { + $provider = $input->getOption('provider'); + + return is_string($provider) && trim($provider) !== ''; + } + + private function providerUpdateFailure(string $status): string { + return match ($status) { + ProviderRegistrationEditor::NOT_FOUND => 'file does not exist or is not readable', + ProviderRegistrationEditor::NOT_WRITABLE => 'file is not writable', + ProviderRegistrationEditor::MISSING_ANCHOR => 'file does not contain a generated database provider registration point', + ProviderRegistrationEditor::MISSING_MARKER => 'file does not contain the generated database provider markers', + ProviderRegistrationEditor::IMPORT_COLLISION => 'another class declaration or import uses the same short class name', + ProviderRegistrationEditor::PARSE_FAILED => 'file could not be parsed as PHP', + ProviderRegistrationEditor::WRITE_FAILED => 'file could not be written', + default => 'provider could not be updated', + }; + } + + private function absolutePath(string $path): string { + $path = trim($path); + + if (str_starts_with($path, '/')) { + return rtrim($path, '/'); + } + + return $this->rootPath . '/' . trim($path, '/'); + } + + private function relativePath(string $path): string { + $root = rtrim($this->rootPath, '/') . '/'; + + if (str_starts_with($path, $root)) { + return substr($path, strlen($root)); + } + + return $path; + } + + private function validNamespace(string $namespace): string { + if (! preg_match('/^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)*$/', $namespace)) { + throw new RuntimeException(sprintf('Namespace "%s" is not a valid PHP namespace.', $namespace)); + } + + return $namespace; + } + + private function runtimeDependencyWarning(): ?string { + $composerPath = $this->rootPath . '/composer.json'; + + if (! is_readable($composerPath)) { + return null; + } + + $composer = json_decode((string) file_get_contents($composerPath), true); + + if (! is_array($composer)) { + return null; + } + + $require = is_array($composer['require'] ?? null) ? $composer['require'] : []; + $requireDev = is_array($composer['require-dev'] ?? null) ? $composer['require-dev'] : []; + + if ($this->hasFoundationRuntimeDependency($require)) { + return null; + } + + if ($this->hasFoundationRuntimeDependency($requireDev)) { + return 'this migration uses Foundation Database classes, but the Foundation runtime package is only in require-dev. Move stellarwp/foundation-database or stellarwp/foundation to require before shipping this migration.'; + } + + return 'this migration uses Foundation Database classes. Run composer require stellarwp/foundation-database, or require stellarwp/foundation, before shipping this migration.'; + } + + /** + * @param array $dependencies + */ + private function hasFoundationRuntimeDependency(array $dependencies): bool { + return array_key_exists('stellarwp/foundation-database', $dependencies) + || array_key_exists('stellarwp/foundation', $dependencies); + } +} diff --git a/src/Cli/Commands/Make/Database/ProviderCommand.php b/src/Cli/Commands/Make/Database/ProviderCommand.php new file mode 100644 index 0000000..7247449 --- /dev/null +++ b/src/Cli/Commands/Make/Database/ProviderCommand.php @@ -0,0 +1,185 @@ +setDescription('Generate a Foundation database provider class.') + ->addArgument('name', InputArgument::OPTIONAL, 'Provider class name, e.g. Provider or Database_Provider.', 'Provider') + ->addOption('namespace', null, InputOption::VALUE_REQUIRED, 'Namespace for the generated provider class.') + ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Directory where the provider class should be written.') + ->addOption('force', null, InputOption::VALUE_NONE, 'Overwrite the file if it already exists.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $file = $this->generatedFile($input); + $this->fileWriter->write($file, (bool) $input->getOption('force')); + } catch (RuntimeException $exception) { + $output->writeln('' . $exception->getMessage() . ''); + + return Command::FAILURE; + } + + $output->writeln(sprintf('Created: %s', $file->relativePath)); + $output->writeln(''); + $output->writeln('Register this provider in your application provider list before adding generated tables and migrations.'); + + $runtimeDependencyWarning = $this->runtimeDependencyWarning(); + + if ($runtimeDependencyWarning !== null) { + $output->writeln(''); + $output->writeln('Runtime dependency missing: ' . $runtimeDependencyWarning); + } + + return Command::SUCCESS; + } + + private function generatedFile(InputInterface $input): GeneratedFile { + $className = $this->classNameResolver->className((string) $input->getArgument('name')); + $project = $this->autoloadResolver->project(); + $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); + $path = $this->path($input, $namespace, $project); + $stub = $this->stubResolver->resolve('database', 'provider', DatabaseStubPath::provider()); + $relative = $this->relativePath($path . '/' . $className . '.php'); + + return new GeneratedFile( + path: $path . '/' . $className . '.php', + relativePath: $relative, + contents: $this->stubRenderer->render($stub, [ + 'namespace' => $namespace, + 'class' => $className, + 'foundation_database_provider' => $project->foundationClass('StellarWP\\Foundation\\Database\\DatabaseProvider'), + 'foundation_service_provider' => $project->foundationClass('StellarWP\\Foundation\\Container\\Contracts\\Provider'), + ]) + ); + } + + private function namespace(InputInterface $input, Psr4Namespace $autoload): string { + $namespace = $input->getOption('namespace'); + + if (is_string($namespace) && trim($namespace) !== '') { + return $this->validNamespace(trim($namespace, '\\')); + } + + return trim($autoload->namespace, '\\') . '\\Database'; + } + + private function path(InputInterface $input, string $namespace, ComposerProject $project): string { + $path = $input->getOption('path'); + + if (is_string($path) && trim($path) !== '') { + return $this->absolutePath($path); + } + + $autoload = $project->psr4NamespaceFor($namespace); + + if ($autoload === null) { + throw new RuntimeException(sprintf( + 'Namespace "%s" is outside the Composer PSR-4 namespaces in composer.json. Pass --path to choose an output directory.', + $namespace + )); + } + + return $this->rootPath . '/' . $autoload->pathFor($namespace); + } + + private function absolutePath(string $path): string { + $path = trim($path); + + if (str_starts_with($path, '/')) { + return rtrim($path, '/'); + } + + return $this->rootPath . '/' . trim($path, '/'); + } + + private function relativePath(string $path): string { + $root = rtrim($this->rootPath, '/') . '/'; + + if (str_starts_with($path, $root)) { + return substr($path, strlen($root)); + } + + return $path; + } + + private function validNamespace(string $namespace): string { + if (! preg_match('/^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)*$/', $namespace)) { + throw new RuntimeException(sprintf('Namespace "%s" is not a valid PHP namespace.', $namespace)); + } + + return $namespace; + } + + private function runtimeDependencyWarning(): ?string { + $composerPath = $this->rootPath . '/composer.json'; + + if (! is_readable($composerPath)) { + return null; + } + + $composer = json_decode((string) file_get_contents($composerPath), true); + + if (! is_array($composer)) { + return null; + } + + $require = is_array($composer['require'] ?? null) ? $composer['require'] : []; + $requireDev = is_array($composer['require-dev'] ?? null) ? $composer['require-dev'] : []; + + if ($this->hasFoundationRuntimeDependency($require)) { + return null; + } + + if ($this->hasFoundationRuntimeDependency($requireDev)) { + return 'this provider uses Foundation Database classes, but the Foundation runtime package is only in require-dev. Move stellarwp/foundation-database or stellarwp/foundation to require before shipping this provider.'; + } + + return 'this provider uses Foundation Database classes. Run composer require stellarwp/foundation-database, or require stellarwp/foundation, before shipping this provider.'; + } + + /** + * @param array $dependencies + */ + private function hasFoundationRuntimeDependency(array $dependencies): bool { + return array_key_exists('stellarwp/foundation-database', $dependencies) + || array_key_exists('stellarwp/foundation', $dependencies); + } +} diff --git a/src/Cli/Commands/Make/Database/ProviderRegistrationEditor.php b/src/Cli/Commands/Make/Database/ProviderRegistrationEditor.php new file mode 100644 index 0000000..e01a1b5 --- /dev/null +++ b/src/Cli/Commands/Make/Database/ProviderRegistrationEditor.php @@ -0,0 +1,187 @@ +addRegistration( + providerPath: $providerPath, + class: $class, + classNamespace: $classNamespace, + marker: self::TABLE_MARKER, + registration: sprintf('$this->container->singleton(%s::class);', $class), + write: true + ); + } + + public function addMigration(string $providerPath, string $class, string $classNamespace): string { + return $this->addMergeArrayVarRegistration( + providerPath: $providerPath, + class: $class, + classNamespace: $classNamespace, + write: true + ); + } + + public function checkTable(string $providerPath, string $class, string $classNamespace): string { + return $this->addRegistration( + providerPath: $providerPath, + class: $class, + classNamespace: $classNamespace, + marker: self::TABLE_MARKER, + registration: sprintf('$this->container->singleton(%s::class);', $class), + write: false + ); + } + + public function checkMigration(string $providerPath, string $class, string $classNamespace): string { + return $this->addMergeArrayVarRegistration( + providerPath: $providerPath, + class: $class, + classNamespace: $classNamespace, + write: false + ); + } + + private function addRegistration(string $providerPath, string $class, string $classNamespace, string $marker, string $registration, bool $write): string { + if (! is_file($providerPath) || ! is_readable($providerPath)) { + return self::NOT_FOUND; + } + + $contents = (string) file_get_contents($providerPath); + + if (! $this->sourceEditor->canParse($contents)) { + return self::PARSE_FAILED; + } + + if (! $this->sourceEditor->hasLineComment($contents, $marker)) { + return self::MISSING_MARKER; + } + + $fullyQualifiedClass = $classNamespace . '\\' . $class; + + if ($this->sourceEditor->hasContainerSingleton($contents, $fullyQualifiedClass)) { + return self::ALREADY_REGISTERED; + } + + if ($this->sourceEditor->hasImportShortNameCollision($contents, $class, $fullyQualifiedClass)) { + return self::IMPORT_COLLISION; + } + + if (! is_writable($providerPath)) { + return self::NOT_WRITABLE; + } + + if (! $write) { + return self::UPDATED; + } + + $contents = $this->sourceEditor->addImport($contents, $fullyQualifiedClass); + + if ($contents === null) { + return self::PARSE_FAILED; + } + + $contents = $this->sourceEditor->insertBeforeLineComment($contents, $marker, $registration); + + if ($contents === null) { + return self::MISSING_MARKER; + } + + if (file_put_contents($providerPath, $contents) === false) { + return self::WRITE_FAILED; + } + + return self::UPDATED; + } + + private function addMergeArrayVarRegistration(string $providerPath, string $class, string $classNamespace, bool $write): string { + if (! is_file($providerPath) || ! is_readable($providerPath)) { + return self::NOT_FOUND; + } + + $contents = (string) file_get_contents($providerPath); + + if (! $this->sourceEditor->canParse($contents)) { + return self::PARSE_FAILED; + } + + $containerExpression = $this->sourceEditor->mergeArrayVarContainerExpression($contents, self::MIGRATIONS_CLASS, self::MIGRATIONS_CONST); + + if ($containerExpression === null || ! $this->sourceEditor->canInsertIntoMergeArrayVar($contents, self::MIGRATIONS_CLASS, self::MIGRATIONS_CONST, self::MIGRATION_MARKER)) { + return self::MISSING_ANCHOR; + } + + $fullyQualifiedClass = $classNamespace . '\\' . $class; + $registration = sprintf('%s->get(%s::class),', $containerExpression, $class); + + if ($this->sourceEditor->mergeArrayVarContainsClass($contents, self::MIGRATIONS_CLASS, self::MIGRATIONS_CONST, $fullyQualifiedClass)) { + return self::ALREADY_REGISTERED; + } + + if ($this->sourceEditor->hasImportShortNameCollision($contents, $class, $fullyQualifiedClass)) { + return self::IMPORT_COLLISION; + } + + if (! is_writable($providerPath)) { + return self::NOT_WRITABLE; + } + + if (! $write) { + return self::UPDATED; + } + + $contents = $this->sourceEditor->addImport($contents, $fullyQualifiedClass); + + if ($contents === null) { + return self::PARSE_FAILED; + } + + $contents = $this->sourceEditor->insertIntoMergeArrayVar( + contents: $contents, + class: self::MIGRATIONS_CLASS, + constant: self::MIGRATIONS_CONST, + statement: $registration, + beforeComment: self::MIGRATION_MARKER + ); + + if ($contents === null) { + return self::MISSING_ANCHOR; + } + + if (file_put_contents($providerPath, $contents) === false) { + return self::WRITE_FAILED; + } + + return self::UPDATED; + } +} diff --git a/src/Cli/Commands/Make/Database/TableCommand.php b/src/Cli/Commands/Make/Database/TableCommand.php new file mode 100644 index 0000000..9a96f2e --- /dev/null +++ b/src/Cli/Commands/Make/Database/TableCommand.php @@ -0,0 +1,321 @@ +setDescription('Generate a Foundation database table class.') + ->addArgument('name', InputArgument::REQUIRED, 'Table class name, e.g. Reports_Table, Reports, or reports.') + ->addOption('namespace', null, InputOption::VALUE_REQUIRED, 'Namespace for the generated table class.') + ->addOption('path', null, InputOption::VALUE_REQUIRED, 'Directory where the table class should be written.') + ->addOption('provider', null, InputOption::VALUE_REQUIRED, 'Database provider file to update when it exists.') + ->addOption('id', null, InputOption::VALUE_REQUIRED, 'Stable migration identifier: nonblank, unpadded, non-integer-like, and at most 191 bytes.') + ->addOption('table', null, InputOption::VALUE_REQUIRED, 'Unprefixed WordPress table name.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $this->validateExplicitProviderUpdate($input); + $file = $this->generatedFile($input); + $this->fileWriter->write($file); + $providerPath = $this->updateProvider($input, $output); + } catch (RuntimeException $exception) { + $output->writeln('' . $exception->getMessage() . ''); + + return Command::FAILURE; + } + + $output->writeln(sprintf('Created: %s', $file->relativePath)); + $output->writeln(''); + $output->writeln('Add this table to a migration with Schema::createOrUpdate() and Schema::drop().'); + + if ($providerPath !== null) { + $output->writeln(sprintf('Updated: %s', $this->relativePath($providerPath))); + } + + $runtimeDependencyWarning = $this->runtimeDependencyWarning(); + + if ($runtimeDependencyWarning !== null) { + $output->writeln(''); + $output->writeln('Runtime dependency missing: ' . $runtimeDependencyWarning); + } + + return Command::SUCCESS; + } + + private function generatedFile(InputInterface $input): GeneratedFile { + $className = $this->classNameResolver->tableClass((string) $input->getArgument('name')); + $project = $this->autoloadResolver->project(); + $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); + $path = $this->path($input, $namespace, $project); + $stub = $this->stubResolver->resolve('database', 'table', DatabaseStubPath::table()); + $relative = $this->relativePath($path . '/' . $className . '.php'); + $table = $this->optionOrDefault($input, 'table', $this->classNameResolver->tableName($className)); + $idOption = $input->getOption('id'); + $id = (new Id(is_string($idOption) ? $idOption : $table . '_table'))->value; + + return new GeneratedFile( + path: $path . '/' . $className . '.php', + relativePath: $relative, + contents: $this->stubRenderer->render($stub, [ + 'namespace' => $namespace, + 'class' => $className, + 'id_php' => $this->phpString($id), + 'table_php' => $this->phpString($table), + 'foundation_database_contract' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Database'), + 'foundation_database_table' => $project->foundationClass('StellarWP\\Foundation\\Database\\Contracts\\Table'), + 'foundation_database_table_definition' => $project->foundationClass('StellarWP\\Foundation\\Database\\Table\\TableDefinition'), + ]) + ); + } + + private function validateExplicitProviderUpdate(InputInterface $input): void { + if (! $this->hasExplicitProvider($input)) { + return; + } + + $project = $this->autoloadResolver->project(); + $className = $this->classNameResolver->tableClass((string) $input->getArgument('name')); + $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); + $providerPath = $this->providerPath($input, $project); + + if (! is_file($providerPath)) { + throw new RuntimeException(sprintf('Could not update database provider "%s": file does not exist.', $this->relativePath($providerPath))); + } + + $status = $this->providerUpdater->checkTable($providerPath, $className, $namespace); + + if ($status === ProviderRegistrationEditor::UPDATED || $status === ProviderRegistrationEditor::ALREADY_REGISTERED) { + return; + } + + throw new RuntimeException(sprintf( + 'Could not update database provider "%s": %s.', + $this->relativePath($providerPath), + $this->providerUpdateFailure($status) + )); + } + + private function updateProvider(InputInterface $input, OutputInterface $output): ?string { + $project = $this->autoloadResolver->project(); + $className = $this->classNameResolver->tableClass((string) $input->getArgument('name')); + $namespace = $this->namespace($input, $project->defaultPsr4Namespace()); + $providerPath = $this->providerPath($input, $project); + $explicit = $this->hasExplicitProvider($input); + + if (! is_file($providerPath)) { + if ($explicit) { + throw new RuntimeException(sprintf('Could not update database provider "%s": file does not exist.', $this->relativePath($providerPath))); + } + + return null; + } + + $status = $this->providerUpdater->addTable($providerPath, $className, $namespace); + + if ($status === ProviderRegistrationEditor::UPDATED) { + return $providerPath; + } + + if ($status === ProviderRegistrationEditor::ALREADY_REGISTERED) { + return null; + } + + if ($explicit) { + throw new RuntimeException(sprintf( + 'Could not update database provider "%s": %s.', + $this->relativePath($providerPath), + $this->providerUpdateFailure($status) + )); + } + + $output->writeln(sprintf( + 'Provider not updated: %s (%s). Register %s manually.', + $this->relativePath($providerPath), + $this->providerUpdateFailure($status), + $className + )); + + return null; + } + + private function optionOrDefault(InputInterface $input, string $option, string $default): string { + $value = $input->getOption($option); + + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + + return $default; + } + + private function phpString(string $value): string { + return var_export($value, true); + } + + private function namespace(InputInterface $input, Psr4Namespace $autoload): string { + $namespace = $input->getOption('namespace'); + + if (is_string($namespace) && trim($namespace) !== '') { + return $this->validNamespace(trim($namespace, '\\')); + } + + return trim($autoload->namespace, '\\') . '\\Database\\Tables'; + } + + private function path(InputInterface $input, string $namespace, ComposerProject $project): string { + $path = $input->getOption('path'); + + if (is_string($path) && trim($path) !== '') { + return $this->absolutePath($path); + } + + $autoload = $project->psr4NamespaceFor($namespace); + + if ($autoload === null) { + throw new RuntimeException(sprintf( + 'Namespace "%s" is outside the Composer PSR-4 namespaces in composer.json. Pass --path to choose an output directory.', + $namespace + )); + } + + return $this->rootPath . '/' . $autoload->pathFor($namespace); + } + + private function providerPath(InputInterface $input, ComposerProject $project): string { + $provider = $input->getOption('provider'); + + if (is_string($provider) && trim($provider) !== '') { + return $this->absolutePath($provider); + } + + $namespace = trim($project->defaultPsr4Namespace()->namespace, '\\') . '\\Database'; + $autoload = $project->psr4NamespaceFor($namespace); + + if ($autoload === null) { + return $this->rootPath . '/src/Database/Provider.php'; + } + + return $this->rootPath . '/' . $autoload->pathFor($namespace) . '/Provider.php'; + } + + private function hasExplicitProvider(InputInterface $input): bool { + $provider = $input->getOption('provider'); + + return is_string($provider) && trim($provider) !== ''; + } + + private function providerUpdateFailure(string $status): string { + return match ($status) { + ProviderRegistrationEditor::NOT_FOUND => 'file does not exist or is not readable', + ProviderRegistrationEditor::NOT_WRITABLE => 'file is not writable', + ProviderRegistrationEditor::MISSING_ANCHOR => 'file does not contain a generated database provider registration point', + ProviderRegistrationEditor::MISSING_MARKER => 'file does not contain the generated database provider markers', + ProviderRegistrationEditor::IMPORT_COLLISION => 'another class declaration or import uses the same short class name', + ProviderRegistrationEditor::PARSE_FAILED => 'file could not be parsed as PHP', + ProviderRegistrationEditor::WRITE_FAILED => 'file could not be written', + default => 'provider could not be updated', + }; + } + + private function absolutePath(string $path): string { + $path = trim($path); + + if (str_starts_with($path, '/')) { + return rtrim($path, '/'); + } + + return $this->rootPath . '/' . trim($path, '/'); + } + + private function relativePath(string $path): string { + $root = rtrim($this->rootPath, '/') . '/'; + + if (str_starts_with($path, $root)) { + return substr($path, strlen($root)); + } + + return $path; + } + + private function validNamespace(string $namespace): string { + if (! preg_match('/^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)*$/', $namespace)) { + throw new RuntimeException(sprintf('Namespace "%s" is not a valid PHP namespace.', $namespace)); + } + + return $namespace; + } + + private function runtimeDependencyWarning(): ?string { + $composerPath = $this->rootPath . '/composer.json'; + + if (! is_readable($composerPath)) { + return null; + } + + $composer = json_decode((string) file_get_contents($composerPath), true); + + if (! is_array($composer)) { + return null; + } + + $require = is_array($composer['require'] ?? null) ? $composer['require'] : []; + $requireDev = is_array($composer['require-dev'] ?? null) ? $composer['require-dev'] : []; + + if ($this->hasFoundationRuntimeDependency($require)) { + return null; + } + + if ($this->hasFoundationRuntimeDependency($requireDev)) { + return 'this table uses Foundation Database classes, but the Foundation runtime package is only in require-dev. Move stellarwp/foundation-database or stellarwp/foundation to require before shipping this table.'; + } + + return 'this table uses Foundation Database classes. Run composer require stellarwp/foundation-database, or require stellarwp/foundation, before shipping this table.'; + } + + /** + * @param array $dependencies + */ + private function hasFoundationRuntimeDependency(array $dependencies): bool { + return array_key_exists('stellarwp/foundation-database', $dependencies) + || array_key_exists('stellarwp/foundation', $dependencies); + } +} diff --git a/src/Cli/Commands/Make/WPCliCommand.php b/src/Cli/Commands/Make/WPCliCommand.php index 1a0693b..5703881 100644 --- a/src/Cli/Commands/Make/WPCliCommand.php +++ b/src/Cli/Commands/Make/WPCliCommand.php @@ -61,7 +61,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln(sprintf('Created: %s', $file->relativePath)); $output->writeln(''); - $output->writeln('Register this command from your WP-CLI provider and configure its $commandPrefix container argument.'); + $output->writeln('Contribute this command to WPCliProvider::COMMANDS from its feature provider.'); $runtimeDependencyWarning = $this->runtimeDependencyWarning(); diff --git a/src/Cli/Commands/Package/PackageScaffolder.php b/src/Cli/Commands/Package/PackageScaffolder.php index 4867081..658b1cc 100644 --- a/src/Cli/Commands/Package/PackageScaffolder.php +++ b/src/Cli/Commands/Package/PackageScaffolder.php @@ -216,7 +216,10 @@ private function closePullRequestWorkflow(): string { on: pull_request_target: - types: [opened] + types: [opened, reopened] + + permissions: + pull-requests: write jobs: run: diff --git a/src/Cli/Generation/GeneratedFileWriter.php b/src/Cli/Generation/GeneratedFileWriter.php index 991d020..2899433 100644 --- a/src/Cli/Generation/GeneratedFileWriter.php +++ b/src/Cli/Generation/GeneratedFileWriter.php @@ -3,16 +3,32 @@ namespace StellarWP\Foundation\Cli\Generation; use RuntimeException; +use StellarWP\Foundation\Cli\Generation\Php\PhpSourceEditor; use StellarWP\Foundation\Cli\Generation\ValueObjects\GeneratedFile; /** * Writes generated files to disk with overwrite protection. */ -final class GeneratedFileWriter +final readonly class GeneratedFileWriter { + public function __construct( + private PhpSourceEditor $sourceEditor + ) { + } + public function write(GeneratedFile $file, bool $force = false): void { - if (file_exists($file->path) && ! $force) { - throw new RuntimeException(sprintf('File already exists: %s. Use --force to overwrite it.', $file->relativePath)); + if (! $this->sourceEditor->canParse($file->contents)) { + throw new RuntimeException(sprintf('Generated file "%s" is not valid PHP.', $file->relativePath)); + } + + $collision = $this->sourceEditor->classImportCollision($file->contents); + + if ($collision !== null) { + throw new RuntimeException(sprintf( + 'Generated file "%s" declares or imports "%s" more than once.', + $file->relativePath, + $collision + )); } $directory = dirname($file->path); @@ -21,7 +37,30 @@ public function write(GeneratedFile $file, bool $force = false): void { throw new RuntimeException(sprintf('Could not create directory "%s".', $directory)); } - if (file_put_contents($file->path, $file->contents) === false) { + if ($force) { + if (file_put_contents($file->path, $file->contents) === false) { + throw new RuntimeException(sprintf('Could not write generated file "%s".', $file->relativePath)); + } + + return; + } + + $handle = @fopen($file->path, 'x'); + + if ($handle === false) { + if (file_exists($file->path)) { + throw new RuntimeException(sprintf('File already exists: %s.', $file->relativePath)); + } + + throw new RuntimeException(sprintf('Could not write generated file "%s".', $file->relativePath)); + } + + $written = fwrite($handle, $file->contents); + $closed = fclose($handle); + + if ($written !== strlen($file->contents) || ! $closed) { + @unlink($file->path); + throw new RuntimeException(sprintf('Could not write generated file "%s".', $file->relativePath)); } } diff --git a/src/Cli/Generation/Php/PhpSourceEditor.php b/src/Cli/Generation/Php/PhpSourceEditor.php new file mode 100644 index 0000000..2afbae3 --- /dev/null +++ b/src/Cli/Generation/Php/PhpSourceEditor.php @@ -0,0 +1,896 @@ +parse($contents) !== null; + } + + /** + * Determine whether a class is already imported under its default short name. + * + * For example, `Acme\Reports\Table` matches `use Acme\Reports\Table;` but + * does not match an import of the same class aliased as another name. + */ + public function hasImport(string $contents, string $fullyQualifiedClass): bool { + $target = trim($fullyQualifiedClass, '\\'); + $alias = basename(str_replace('\\', '/', $target)); + + foreach ($this->imports($contents) as $import) { + if (strcasecmp($import['class'], $target) === 0 && strcasecmp($import['alias'], $alias) === 0) { + return true; + } + } + + return false; + } + + /** + * Determine whether a proposed short name conflicts with another import or class declaration. + * + * For example, generating `ReportsTable` conflicts with + * `use Other\Package\ReportsTable;` and `final class ReportsTable`. + */ + public function hasImportShortNameCollision(string $contents, string $class, string $fullyQualifiedClass): bool { + $target = trim($fullyQualifiedClass, '\\'); + + foreach ($this->imports($contents) as $import) { + if (strcasecmp($import['alias'], $class) === 0 && strcasecmp($import['class'], $target) !== 0) { + return true; + } + } + + $statements = $this->parse($contents); + + if ($statements === null) { + return false; + } + + foreach ($this->topLevelStatements($statements) as $statement) { + if ($statement instanceof Stmt\ClassLike && $statement->name !== null && strcasecmp($statement->name->toString(), $class) === 0) { + return true; + } + } + + return false; + } + + /** + * Return the first class-like declaration or import alias that conflicts in PHP's case-insensitive symbol table. + * + * This detects invalid generated files such as importing `Migration` and + * declaring a class named `migration` in the same namespace. + */ + public function classImportCollision(string $contents): ?string { + $statements = $this->parse($contents); + + if ($statements === null) { + return null; + } + + $aliases = []; + + foreach ($this->imports($contents) as $import) { + $alias = strtolower($import['alias']); + + if (isset($aliases[$alias])) { + return $import['alias']; + } + + $aliases[$alias] = $import['class']; + } + + foreach ($this->topLevelStatements($statements) as $statement) { + if (! $statement instanceof Stmt\ClassLike || $statement->name === null) { + continue; + } + + $name = $statement->name->toString(); + $key = strtolower($name); + + if (isset($aliases[$key])) { + return $name; + } + + $aliases[$key] = $name; + } + + return null; + } + + /** + * Determine whether the source contains an exact standalone line comment. + * + * Marker text embedded in code or a longer comment is intentionally ignored. + */ + public function hasLineComment(string $contents, string $comment): bool { + return $this->lineComment($contents, $comment) !== null; + } + + /** + * Add a class import after existing imports or the namespace declaration. + * + * Existing imports are left unchanged. Null is returned when the source + * cannot be parsed or a safe insertion offset cannot be determined. + */ + public function addImport(string $contents, string $fullyQualifiedClass): ?string { + if ($this->hasImport($contents, $fullyQualifiedClass)) { + return $contents; + } + + $offset = $this->importInsertionOffset($contents); + + if ($offset === null) { + return null; + } + + $import = 'use ' . trim($fullyQualifiedClass, '\\') . ';'; + + return substr($contents, 0, $offset) . $this->importSeparator($contents, $offset) . $import . substr($contents, $offset); + } + + /** + * Insert a statement immediately before an exact standalone marker comment. + * + * The marker's indentation is reused so generated provider registrations + * follow the surrounding source formatting. + */ + public function insertBeforeLineComment(string $contents, string $comment, string $statement): ?string { + $lineComment = $this->lineComment($contents, $comment); + + if ($lineComment === null) { + return null; + } + + return substr($contents, 0, $lineComment->lineStartOffset) + . $lineComment->indent . $statement . "\n" + . substr($contents, $lineComment->lineStartOffset); + } + + /** + * Determine whether a matching `mergeArrayVar()` registration list can be edited safely. + * + * For example, this locates the array returned from + * `mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn (C $c) => [...])`. + * Inline arrays are rejected because inserting a formatted line would be unsafe. + */ + public function canInsertIntoMergeArrayVar(string $contents, string $class, string $constant, ?string $beforeComment = null): bool { + $target = $this->mergeArrayVarTarget($contents, $class, $constant); + + if ($target === null) { + return false; + } + + $insertion = $beforeComment === null ? null : $this->lineComment($contents, $beforeComment, $target->registrationList); + $insertion ??= $this->arrayInsertion($contents, $target->registrationList); + + return $insertion !== null; + } + + /** + * Return the container expression used by the first editable `mergeArrayVar()` callback. + * + * This returns expressions such as `$c` for callback registrations or + * `$this->container` when a direct array is supplied. + */ + public function mergeArrayVarContainerExpression(string $contents, string $class, string $constant): ?string { + return $this->mergeArrayVarTarget($contents, $class, $constant)?->containerExpression; + } + + /** + * Determine whether the provider registers a class with `$this->container->singleton()`. + * + * PHP names are resolved before comparison, so imported aliases and + * namespace-relative class references are recognized correctly. + */ + public function hasContainerSingleton(string $contents, string $fullyQualifiedClass): bool { + $statements = $this->resolvedStatements($contents); + + if ($statements === null) { + return false; + } + + return $this->findNode( + $statements, + fn (Node $node): bool => $this->isContainerSingleton($node, $fullyQualifiedClass) + ) !== null; + } + + /** + * Determine whether any matching `mergeArrayVar()` contribution resolves a class from its container. + * + * For example, this recognizes `$c->get(CreateReportsTable::class)` inside + * every contribution to `DatabaseProvider::MIGRATIONS`. + */ + public function mergeArrayVarContainsClass(string $contents, string $class, string $constant, string $fullyQualifiedClass): bool { + foreach ($this->mergeArrayVarTargets($contents, $class, $constant) as $target) { + foreach ($target->registrationList->items as $item) { + if ($this->isContainerGet($item->value, $target->containerExpression, $fullyQualifiedClass)) { + return true; + } + } + } + + return false; + } + + /** + * Insert a statement into the first safely editable matching `mergeArrayVar()` array. + * + * A marker inside the registration array is preferred when supplied; + * otherwise the statement is inserted immediately before the closing bracket. + */ + public function insertIntoMergeArrayVar(string $contents, string $class, string $constant, string $statement, ?string $beforeComment = null): ?string { + $target = $this->mergeArrayVarTarget($contents, $class, $constant); + + if ($target === null) { + return null; + } + + $insertion = $beforeComment === null ? null : $this->lineComment($contents, $beforeComment, $target->registrationList); + $insertion ??= $this->arrayInsertion($contents, $target->registrationList); + + if ($insertion === null) { + return null; + } + + return substr($contents, 0, $insertion->lineStartOffset) + . $insertion->indent . $statement . "\n" + . substr($contents, $insertion->lineStartOffset); + } + + /** + * Collect top-level class imports and their effective aliases. + * + * Function and constant imports are excluded because they do not occupy the + * class symbol table used by generated declarations. + * + * @return list + */ + private function imports(string $contents): array { + $statements = $this->parse($contents); + + if ($statements === null) { + return []; + } + + $imports = []; + + foreach ($this->topLevelStatements($statements) as $statement) { + if ($statement instanceof Stmt\Use_) { + $imports = array_merge($imports, $this->useImports($statement->uses, $statement->type)); + } + + if ($statement instanceof Stmt\GroupUse) { + $imports = array_merge($imports, $this->groupUseImports($statement)); + } + } + + return $imports; + } + + /** + * Normalize a standard `use` statement into class and alias pairs. + * + * @param array $uses + * + * @return list + */ + private function useImports(array $uses, int $type): array { + if ($type !== Stmt\Use_::TYPE_NORMAL) { + return []; + } + + $imports = []; + + foreach ($uses as $use) { + $imports[] = [ + 'class' => $use->name->toString(), + 'alias' => $use->getAlias()->toString(), + ]; + } + + return $imports; + } + + /** + * Normalize a grouped `use` statement into complete class and alias pairs. + * + * For example, `use Acme\{One, Two as Alias};` becomes two complete imports. + * + * @return list + */ + private function groupUseImports(Stmt\GroupUse $groupUse): array { + $imports = []; + $prefix = $groupUse->prefix->toString(); + + foreach ($groupUse->uses as $use) { + $type = $use->type === Stmt\Use_::TYPE_UNKNOWN ? $groupUse->type : $use->type; + + if ($type !== Stmt\Use_::TYPE_NORMAL) { + continue; + } + + $imports[] = [ + 'class' => $prefix . '\\' . $use->name->toString(), + 'alias' => $use->getAlias()->toString(), + ]; + } + + return $imports; + } + + /** + * Find where a new import belongs without disturbing namespace or declaration syntax. + * + * Imports are placed after the last existing import, then after the namespace, + * then after a strict-types declaration, or finally after the PHP opening tag. + */ + private function importInsertionOffset(string $contents): ?int { + $statements = $this->parse($contents); + + if ($statements === null) { + return null; + } + + $lastUse = null; + + foreach ($this->topLevelStatements($statements) as $statement) { + if ($statement instanceof Stmt\Use_ || $statement instanceof Stmt\GroupUse) { + $lastUse = $statement; + } + } + + if ($lastUse instanceof Node) { + return $this->lineEndOffset($contents, $lastUse->getEndFilePos() + 1); + } + + foreach ($statements as $statement) { + if ($statement instanceof Stmt\Namespace_) { + return $this->namespaceDeclarationEndOffset($contents, $statement); + } + } + + foreach ($statements as $statement) { + if ($statement instanceof Stmt\Declare_) { + return $statement->getEndFilePos() + 1; + } + } + + return $this->openingTagEndOffset($contents); + } + + /** + * Find the byte offset immediately after a semicolon-style or braced namespace declaration. + */ + private function namespaceDeclarationEndOffset(string $contents, Stmt\Namespace_ $namespace): ?int { + foreach ($this->lexer->tokenize($contents) as $token) { + if ($token->pos < $namespace->getStartFilePos()) { + continue; + } + + if ($token->id === ord(';') || $token->id === ord('{')) { + return $token->getEndPos(); + } + } + + return null; + } + + /** + * Find the byte offset immediately after the PHP opening tag, or zero when none exists. + */ + private function openingTagEndOffset(string $contents): int { + foreach ($this->lexer->tokenize($contents) as $token) { + if ($token->id === T_OPEN_TAG) { + return $token->getEndPos(); + } + } + + return 0; + } + + /** + * Choose spacing for a new import based on whether an import block already exists. + */ + private function importSeparator(string $contents, int $offset): string { + $before = substr($contents, 0, $offset); + + if (preg_match('/^use\s/m', $before) === 1) { + return "\n"; + } + + return "\n\n"; + } + + /** + * Find an exact standalone line comment and preserve its source indentation. + * + * When `$within` is provided, only comments inside that syntax node are + * considered, preventing an unrelated marker elsewhere in the provider from matching. + */ + private function lineComment(string $contents, string $comment, ?Node $within = null): ?LineComment { + foreach ($this->lexer->tokenize($contents) as $token) { + if (! $token->is(T_COMMENT) || trim($token->text) !== $comment) { + continue; + } + + if ($within !== null && ($token->pos < $within->getStartFilePos() || $token->pos > $within->getEndFilePos())) { + continue; + } + + $lineStartOffset = $this->lineStartOffset($contents, $token->pos); + $indent = substr($contents, $lineStartOffset, $token->pos - $lineStartOffset); + + if (trim($indent) !== '') { + continue; + } + + return new LineComment( + indent: $indent, + lineStartOffset: $lineStartOffset, + commentStartOffset: $token->pos + ); + } + + return null; + } + + /** + * Return the first matching `mergeArrayVar()` target whose array has a safe line insertion point. + */ + private function mergeArrayVarTarget(string $contents, string $class, string $constant): ?MergeArrayVarTarget { + foreach ($this->mergeArrayVarTargets($contents, $class, $constant) as $target) { + if ($this->arrayInsertion($contents, $target->registrationList) !== null) { + return $target; + } + } + + return null; + } + + /** + * Collect every recognized `mergeArrayVar()` contribution for a class constant. + * + * For example, separate feature providers may each contribute to + * `DatabaseProvider::MIGRATIONS`; all contributions must be searched for duplicates. + * + * @return list + */ + private function mergeArrayVarTargets(string $contents, string $class, string $constant): array { + $statements = $this->resolvedStatements($contents); + + if ($statements === null) { + return []; + } + + $targets = []; + $calls = (new NodeFinder())->find( + $statements, + fn (Node $node): bool => $this->isMergeArrayVarCall($node, $class, $constant) + ); + + foreach ($calls as $call) { + if (! $call instanceof Expr\MethodCall) { + continue; + } + + $target = $this->mergeArrayVarTargetFromCall($call); + + if ($target !== null) { + $targets[] = $target; + } + } + + return $targets; + } + + /** + * Determine whether a syntax node calls `$this->container->mergeArrayVar()` for the requested constant. + * + * Strauss-prefixed references are accepted when their resolved class ends + * with the requested Foundation class namespace. + */ + private function isMergeArrayVarCall(Node $node, string $class, string $constant): bool { + if (! $node instanceof Expr\MethodCall || ! $node->name instanceof Node\Identifier || $node->name->toString() !== 'mergeArrayVar') { + return false; + } + + if (! $this->isThisContainer($node->var)) { + return false; + } + + $firstArgument = $node->args[0]->value ?? null; + + if (! $firstArgument instanceof Expr\ClassConstFetch || ! $firstArgument->class instanceof Node\Name || ! $firstArgument->name instanceof Node\Identifier) { + return false; + } + + if ($firstArgument->name->toString() !== $constant) { + return false; + } + + return $this->isClassReference($firstArgument->class, $class, true); + } + + /** + * Determine whether a syntax node is a singleton registration for the requested class. + */ + private function isContainerSingleton(Node $node, string $class): bool { + if (! $node instanceof Expr\MethodCall || ! $node->name instanceof Node\Identifier || $node->name->toString() !== 'singleton') { + return false; + } + + if (! $this->isThisContainer($node->var)) { + return false; + } + + $argument = $node->args[0]->value ?? null; + + return $argument instanceof Expr\ClassConstFetch + && $argument->class instanceof Node\Name + && $argument->name instanceof Node\Identifier + && $argument->name->toString() === 'class' + && $this->isClassReference($argument->class, $class); + } + + /** + * Determine whether an array item resolves the requested class from the callback container. + * + * The receiver must match the callback parameter, such as `$c` in + * `static fn (C $c): array => [$c->get(Service::class)]`. + */ + private function isContainerGet(Node $node, string $containerExpression, string $class): bool { + if (! $node instanceof Expr\MethodCall || ! $node->name instanceof Node\Identifier || $node->name->toString() !== 'get') { + return false; + } + + if (! $this->matchesContainerExpression($node->var, $containerExpression)) { + return false; + } + + $argument = $node->args[0]->value ?? null; + + return $argument instanceof Expr\ClassConstFetch + && $argument->class instanceof Node\Name + && $argument->name instanceof Node\Identifier + && $argument->name->toString() === 'class' + && $this->isClassReference($argument->class, $class); + } + + /** + * Determine whether a node represents the expected callback variable or provider container property. + */ + private function matchesContainerExpression(Node $node, string $containerExpression): bool { + if ($containerExpression === '$this->container') { + return $this->isThisContainer($node); + } + + return $node instanceof Expr\Variable + && is_string($node->name) + && '$' . $node->name === $containerExpression; + } + + /** + * Compare a PHP name using the fully resolved name attached by PHP-Parser. + */ + private function isClassReference(Node\Name $name, string $class, bool $allowPrefixed = false): bool { + $resolved = $name->getAttribute('resolvedName') ?? $name->getAttribute('namespacedName'); + + return $resolved instanceof Node\Name + && $this->classMatches($resolved->toString(), $class, $allowPrefixed); + } + + /** + * Compare class names case-insensitively, optionally allowing a namespace prefix. + * + * Prefix matching supports rewritten references such as + * `Acme\Prefixed\StellarWP\Foundation\Database\DatabaseProvider`. + */ + private function classMatches(string $reference, string $class, bool $allowPrefixed = false): bool { + $reference = trim($reference, '\\'); + $class = trim($class, '\\'); + + if (strcasecmp($reference, $class) === 0) { + return true; + } + + return $allowPrefixed + && strlen($reference) > strlen($class) + && strcasecmp(substr($reference, -strlen($class) - 1), '\\' . $class) === 0; + } + + /** + * Determine whether a node is exactly the provider's `$this->container` property. + */ + private function isThisContainer(Node $node): bool { + return $node instanceof Expr\PropertyFetch + && $node->var instanceof Expr\Variable + && $node->var->name === 'this' + && $node->name instanceof Node\Identifier + && $node->name->toString() === 'container'; + } + + /** + * Extract the editable registration array and its container expression from a merge call. + * + * Supported values are a direct array, an arrow function returning an array, + * or a closure with an explicit array return statement. + */ + private function mergeArrayVarTargetFromCall(Expr\MethodCall $call): ?MergeArrayVarTarget { + $callback = $call->args[1]->value ?? null; + + if ($callback instanceof Expr\Array_) { + return new MergeArrayVarTarget( + registrationList: $callback, + containerExpression: '$this->container' + ); + } + + if ($callback instanceof Expr\ArrowFunction && $callback->expr instanceof Expr\Array_) { + $containerExpression = $this->callbackContainerExpression($callback); + + if ($containerExpression === null) { + return null; + } + + return new MergeArrayVarTarget( + registrationList: $callback->expr, + containerExpression: $containerExpression + ); + } + + if (! $callback instanceof Expr\Closure) { + return null; + } + + foreach ($callback->stmts as $statement) { + if ($statement instanceof Stmt\Return_ && $statement->expr instanceof Expr\Array_) { + $containerExpression = $this->callbackContainerExpression($callback); + + if ($containerExpression === null) { + return null; + } + + return new MergeArrayVarTarget( + registrationList: $statement->expr, + containerExpression: $containerExpression + ); + } + } + + return null; + } + + /** + * Return the first callback parameter as a source expression such as `$c`. + */ + private function callbackContainerExpression(Expr\Closure|Expr\ArrowFunction $callback): ?string { + $parameter = $callback->params[0] ?? null; + + if ($parameter === null || ! $parameter->var instanceof Expr\Variable || ! is_string($parameter->var->name)) { + return null; + } + + return '$' . $parameter->var->name; + } + + /** + * Calculate a formatting-preserving insertion before a multiline array's closing bracket. + * + * Single-line arrays return null because inserting a line without reprinting the + * complete syntax tree could corrupt formatting or comments. + */ + private function arrayInsertion(string $contents, Expr\Array_ $array): ?LineInsertion { + $indent = null; + + if ($array->items !== []) { + $firstItem = $array->items[0]; + $lineStartOffset = $this->lineStartOffset($contents, $firstItem->getStartFilePos()); + $firstItemIndent = substr($contents, $lineStartOffset, $firstItem->getStartFilePos() - $lineStartOffset); + + if (trim($firstItemIndent) === '') { + $indent = $firstItemIndent; + } + } + + $closingOffset = $array->getEndFilePos(); + $lineStartOffset = $this->lineStartOffset($contents, $closingOffset); + $closingIndent = substr($contents, $lineStartOffset, $closingOffset - $lineStartOffset); + + if (trim($closingIndent) !== '') { + return null; + } + + return new LineInsertion( + lineStartOffset: $lineStartOffset, + indent: $indent ?? $this->childIndent($closingIndent) + ); + } + + /** + * Add one indentation level while preserving the surrounding tab or space style. + */ + private function childIndent(string $indent): string { + if ($indent === '' || str_contains($indent, "\t")) { + return $indent . "\t"; + } + + return $indent . ' '; + } + + /** + * Search statements depth-first and return the first node accepted by a predicate. + * + * @param array $statements + * @param callable(Node): bool $predicate + */ + private function findNode(array $statements, callable $predicate): ?Node { + foreach ($statements as $statement) { + $match = $this->findMatchingNode($statement, $predicate); + + if ($match instanceof Node) { + return $match; + } + } + + return null; + } + + /** + * Search one syntax node and its descendants depth-first. + * + * @param callable(Node): bool $predicate + */ + private function findMatchingNode(Node $node, callable $predicate): ?Node { + if ($predicate($node)) { + return $node; + } + + foreach ($node->getSubNodeNames() as $name) { + $value = $node->{$name}; + + if ($value instanceof Node) { + $match = $this->findMatchingNode($value, $predicate); + + if ($match instanceof Node) { + return $match; + } + } + + if (is_array($value)) { + foreach ($value as $item) { + if (! $item instanceof Node) { + continue; + } + + $match = $this->findMatchingNode($item, $predicate); + + if ($match instanceof Node) { + return $match; + } + } + } + } + + return null; + } + + /** + * Return the byte offset of the first character on the line containing an offset. + */ + private function lineStartOffset(string $contents, int $offset): int { + $previousNewline = strrpos(substr($contents, 0, $offset), "\n"); + + if ($previousNewline === false) { + return 0; + } + + return $previousNewline + 1; + } + + /** + * Return the byte offset of the newline ending a line, or the end of the source. + */ + private function lineEndOffset(string $contents, int $offset): int { + $nextNewline = strpos($contents, "\n", $offset); + + if ($nextNewline === false) { + return strlen($contents); + } + + return $nextNewline; + } + + /** + * Return statements inside the first namespace, or the original root statements. + * + * Generator targets use one namespace per file, so imports and declarations are + * inspected only within that file-level namespace. + * + * @param array $statements + * + * @return array + */ + private function topLevelStatements(array $statements): array { + foreach ($statements as $statement) { + if ($statement instanceof Stmt\Namespace_) { + return $statement->stmts; + } + } + + return $statements; + } + + /** + * Parse source while converting PHP-Parser syntax failures to null. + * + * @return array|null + */ + private function parse(string $contents): ?array { + try { + return $this->parserFactory->createForNewestSupportedVersion()->parse($contents); + } catch (Error) { + return null; + } + } + + /** + * Parse source and attach PHP-resolved names without replacing original nodes. + * + * Keeping original nodes preserves source offsets for edits while resolvedName and + * namespacedName attributes provide PHP-accurate comparisons for aliases and prefixes. + * + * @return array|null + */ + private function resolvedStatements(string $contents): ?array { + $statements = $this->parse($contents); + + if ($statements === null) { + return null; + } + + $traverser = new NodeTraverser(); + $traverser->addVisitor(new NameResolver(null, ['replaceNodes' => false])); + + try { + return array_values(array_filter( + $traverser->traverse($statements), + static fn (Node $node): bool => $node instanceof Stmt + )); + } catch (Error) { + return null; + } + } +} diff --git a/src/Cli/Generation/Php/ValueObjects/LineComment.php b/src/Cli/Generation/Php/ValueObjects/LineComment.php new file mode 100644 index 0000000..65a9c82 --- /dev/null +++ b/src/Cli/Generation/Php/ValueObjects/LineComment.php @@ -0,0 +1,16 @@ +classNameFromWords($input); + } + public function commandClass(string $input): string { $words = $this->words($input); @@ -33,6 +37,42 @@ public function commandClass(string $input): string { return $className; } + public function tableClass(string $input): string { + $words = $this->words($input); + + if ($words === []) { + throw new RuntimeException(sprintf('Could not create a table class name from "%s".', $input)); + } + + if (strtolower((string) end($words)) !== 'table') { + $words[] = 'table'; + } + + return $this->validClassName(implode('_', array_map($this->pascalWord(...), $words)), $input); + } + + public function tableName(string $className): string { + $words = $this->words((string) preg_replace('/_?Table$/', '', $className)); + + if ($words === []) { + return strtolower($className); + } + + return implode('_', array_map(strtolower(...), $words)); + } + + public function migrationId(string $className, ?\DateTimeImmutable $now = null): string { + $words = $this->words($className); + + if ($words === []) { + throw new RuntimeException(sprintf('Could not create a migration id from "%s".', $className)); + } + + $now ??= new \DateTimeImmutable(); + + return $now->format('Y_m_d_His') . '_' . implode('_', array_map(strtolower(...), $words)); + } + public function subcommand(string $className): string { $words = $this->words((string) preg_replace('/_?Command$/', '', $className)); @@ -49,6 +89,16 @@ public function description(string $className): string { return ucfirst(implode(' ', array_map(strtolower(...), $words))) . '.'; } + private function classNameFromWords(string $input): string { + $words = $this->words($input); + + if ($words === []) { + throw new RuntimeException(sprintf('Could not create a class name from "%s".', $input)); + } + + return $this->validClassName(implode('_', array_map($this->pascalWord(...), $words)), $input); + } + /** * @return list */ @@ -69,4 +119,12 @@ private function pascalWord(string $word): string { return ucfirst($word); } + + private function validClassName(string $className, string $input): string { + if (! preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $className)) { + throw new RuntimeException(sprintf('Could not create a valid PHP class name from "%s".', $input)); + } + + return $className; + } } diff --git a/src/Cli/README.md b/src/Cli/README.md index 6f331c9..e6ac6b5 100644 --- a/src/Cli/README.md +++ b/src/Cli/README.md @@ -3,121 +3,17 @@ > [!WARNING] > **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). -Foundation CLI tooling for generating application code and maintaining the Foundation monorepo. +Foundation CLI provides developer tooling for generating Foundation-aware project code and maintaining the Foundation monorepo. ## Installation Install this package as a development dependency in consuming projects: -```bash +```shell composer require --dev stellarwp/foundation-cli ``` -`foundation-cli` is build-time tooling. It should not be registered in a WordPress plugin application and should not be packaged in production plugin zips. Use Composer's `--no-dev` install mode for production builds so the CLI, Symfony Console, generators, and local tooling stay out of the runtime artifact. +## Documentation -If a generated WP-CLI command ships with the plugin, install `stellarwp/foundation-wpcli` as a normal runtime dependency: - -```bash -composer require stellarwp/foundation-wpcli -``` - -## Generators - -List all available commands: - -```bash -vendor/bin/foundation list -``` - -Foundation CLI includes generators for packages that own generated class shapes. For example, the WPCli package provides a generator for command classes: - -```bash -vendor/bin/foundation make:wpcli-command Sync_Products_Command -``` - -Do not add `StellarWP\Foundation\Cli\CliProvider` to the consuming WordPress plugin's provider list. That provider only boots the Foundation Symfony Console application for the `foundation` binary. Register generated WP-CLI commands from the plugin's own WP-CLI provider using `stellarwp/foundation-wpcli`. - -See the WPCli package README for WP-CLI generator behavior, options, and stub overrides. - -## Foundation Monorepo Maintenance - -The `package:create` command is for maintainers working inside the Foundation monorepo. It creates local split-package scaffolding and can create GitHub repositories for Foundation split packages. - -Create a split repository for a new Foundation package: - -```bash -vendor/bin/foundation package:create Log -``` - -If the package does not exist yet, the command asks whether to create the local scaffold in `src/` and asks for the Composer package name. For example, `WPCli` defaults to `stellarwp/foundation-wpcli`. After scaffolding, it runs `composer monorepo merge` so the root package metadata includes the new split package. - -By default, commands that change external systems run as a dry run. Pass `--apply` to execute the generated repository actions. - -In the Foundation monorepo, the root Composer script can also be used: - -```bash -composer run foundation -- package:create Log -``` - -## Custom Commands - -Applications can build their own Foundation CLI by creating Symfony Console commands and registering them with `StellarWP\Foundation\Cli\Application`. - -```php -writeln('Cache cleared.'); - - return Command::SUCCESS; - } -} -``` - -For one or more related commands, group them behind a command provider. - -```php -run()); -``` - -When commands need shared services, register them in your container and pass constructed commands or command providers into the `Application`. +See the [Foundation CLI documentation](https://foundation.nexcess.dev/tooling/foundation-cli/) +for project generators, stub overrides, Strauss support, custom commands, and monorepo maintenance. diff --git a/src/Cli/composer.json b/src/Cli/composer.json index 89ec0f8..ca3c7cd 100644 --- a/src/Cli/composer.json +++ b/src/Cli/composer.json @@ -9,7 +9,9 @@ }, "require": { "php": ">=8.3", + "nikic/php-parser": ">=5.0 <6.0", "stellarwp/foundation-container": "^2.0", + "stellarwp/foundation-database": "^2.0", "stellarwp/foundation-wpcli": "^2.0", "symfony/console": ">=5.4" }, diff --git a/src/Container/.github/workflows/close-pull-request.yml b/src/Container/.github/workflows/close-pull-request.yml index 7c34d2e..a2353e6 100644 --- a/src/Container/.github/workflows/close-pull-request.yml +++ b/src/Container/.github/workflows/close-pull-request.yml @@ -2,7 +2,10 @@ name: Close Pull Request on: pull_request_target: - types: [opened] + types: [opened, reopened] + +permissions: + pull-requests: write jobs: run: diff --git a/src/Container/ContainerAdapter.php b/src/Container/ContainerAdapter.php index 679cb82..0a960cc 100644 --- a/src/Container/ContainerAdapter.php +++ b/src/Container/ContainerAdapter.php @@ -88,11 +88,6 @@ public function give(mixed $implementation): void { $this->container->give($implementation); } - public function instance(mixed $id, array $buildArgs = [], ?array $afterBuildMethods = null): Closure { - // @phpstan-ignore-next-line invalid DocBlock comments in DI52 - return $this->container->instance($id, $buildArgs, $afterBuildMethods); - } - /** * {@inheritDoc} * @@ -102,6 +97,11 @@ public function mergeArrayVar(string $id, mixed $implementation): void { $this->container->mergeArrayVar($id, $implementation); } + public function instance(mixed $id, array $buildArgs = [], ?array $afterBuildMethods = null): Closure { + // @phpstan-ignore-next-line invalid DocBlock comments in DI52 + return $this->container->instance($id, $buildArgs, $afterBuildMethods); + } + /** * @param class-string|string|object $id * diff --git a/src/Container/README.md b/src/Container/README.md index 7701d47..ac6d43c 100644 --- a/src/Container/README.md +++ b/src/Container/README.md @@ -3,8 +3,10 @@ > [!WARNING] > **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). -The DI Container configuration and Service Provider implementation, utilizing -[di52](https://github.com/lucatume/di52). +Foundation Container adapts [DI52](https://github.com/lucatume/di52) behind a +shared container contract and service provider base class. It supports +autowiring, contextual bindings, lazy WordPress callbacks, cross-provider +collections, and decorator chains. ## Installation @@ -12,123 +14,7 @@ The DI Container configuration and Service Provider implementation, utilizing composer require stellarwp/foundation-container ``` -## Container Configuration - -Create a new ContainerAdapter, by passing in an instance of di52: - -```php -load(); -} - -// This implements the Contracts/Container.php interface. -$container = new ContainerAdapter(new Container()); - -// Bind the concrete to the interface, so anytime we ask for a container we get this one. -$container->bind(Container::class, $container); - -// Register our project's configuration. See "Making a config.php" below for more detail. -$container->bind(Dot::class, new Dot(require_once dirname(__FILE__) . '/config.php')); - -// Register any service providers in the container. -$providers = [ - StellarWP\YourProject\ServiceProvider::class, - // as many as you have made... -]; - -foreach ( $providers as $provider ) { - $container->register($provider); -} -``` - -Here is an example Service Provider: - -```php -container->bind(Storage::class, LocalStorage::class); - $this->container->when(LocalStorage::class) - ->needs('$storagePath') - ->give($this->config->get('storage_path')); - } -} -``` - -## Environment Variable Configuration - -This library uses the [Dot](https://github.com/adbario/php-dot-notation) package to set and fetch configuration -values, which are initially provided via Environment Variables, either manually set or via an `.env` file -utilizing [vlucas/phpdotenv](https://github.com/vlucas/phpdotenv) to read them. - -Each Service Provider will have access to Dot via the `$this->config` property, it is best practice to only -access configuration variables from a Service Provider and never in your concrete classes. - -### Making a config.php - -A sample config.php for a project. Note: we fall back to sane defaults if the environment variable isn't available. - -```php - $_ENV['SOME_KEY'] ?? '', - 'log' => [ - 'level' => $_ENV['LOG_LEVEL'] ?? 'debug', - 'channel' => $_ENV['LOG_CHANNEL'] ?? 'null', - 'channels' => [ - 'errorlog' => [], - 'console' => [ - 'with' => [ - 'stream' => 'php://stdout', - ], - ], - ], - ], -]; -``` - -Inside a Provider, we can then access deep variables with dot notation, e.g. - -```php -// Get the console log stream type from config.php. -$stream = $this->config->get('log.channels.console.with.stream'); - -// Get the log level. -$level = $this->config->get('log.level'); -``` - -> 💡 If using a `config.php` in a WordPress plugin, simply add your configured $_ENV vars in your wp-config.php. - -```php -// Inside wp-config.php - -// App configuration. -$_ENV['SOME_KEY'] = 'abcd-1234'; -$_ENV['LOG_LEVEL'] = 'info'; -$_ENV['LOG_CHANNEL'] = 'errorlog'; -``` +## Documentation +See the [Foundation Container documentation](https://foundation.nexcess.dev/components/container/) +for application setup, bindings, provider collections, lazy callbacks, and testing. diff --git a/src/Container/Traits/ResolvesFoundationPrefix.php b/src/Container/Traits/ResolvesFoundationPrefix.php new file mode 100644 index 0000000..0a1ef97 --- /dev/null +++ b/src/Container/Traits/ResolvesFoundationPrefix.php @@ -0,0 +1,33 @@ +config->get('foundation.prefix'); + + if ($prefix === null || $prefix === '') { + return 'nx'; + } + + if (! is_string($prefix) || preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $prefix) !== 1) { + throw new InvalidArgumentException('The Foundation prefix must use lowercase kebab-case, for example "your-plugin".'); + } + + return $prefix; + } +} diff --git a/src/Database/.gitattributes b/src/Database/.gitattributes new file mode 100644 index 0000000..e82014a --- /dev/null +++ b/src/Database/.gitattributes @@ -0,0 +1,7 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore paths when git creates an archive of this package +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore diff --git a/src/Database/.github/workflows/close-pull-request.yml b/src/Database/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..ddbc0e9 --- /dev/null +++ b/src/Database/.github/workflows/close-pull-request.yml @@ -0,0 +1,16 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + pull-requests: write + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.

Thanks!" diff --git a/src/Database/.gitignore b/src/Database/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/src/Database/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/src/Database/Cli/Migrate.php b/src/Database/Cli/Migrate.php new file mode 100644 index 0000000..f782760 --- /dev/null +++ b/src/Database/Cli/Migrate.php @@ -0,0 +1,196 @@ +container, $commandPrefix); + } + + /** + * @param list $args + * @param array $assocArgs + */ + public function runCommand(array $args = [], array $assocArgs = []): int { + $run = (bool) get_flag_value($assocArgs, self::FLAG_RUN, false); + $rollback = (bool) get_flag_value($assocArgs, self::FLAG_ROLLBACK, false); + $refresh = (bool) get_flag_value($assocArgs, self::FLAG_REFRESH, false); + $dropStore = (bool) get_flag_value($assocArgs, self::FLAG_DROP_STORE, false); + $initialize = (bool) get_flag_value($assocArgs, self::FLAG_INITIALIZE, false); + + $this->assertSingleOperation([ + self::FLAG_RUN => $run, + self::FLAG_ROLLBACK => $rollback, + self::FLAG_REFRESH => $refresh, + self::FLAG_DROP_STORE => $dropStore, + self::FLAG_INITIALIZE => $initialize, + ]); + + if (($run || $rollback || $refresh || $dropStore) && ! $this->migrator->isInitialized()) { + WP_CLI::error(sprintf( + 'Migration storage is not initialized. Run `wp %s --initialize` first.', + $this->command() + )); + } + + if ($dropStore) { + WP_CLI::confirm('Drop only the migration ledger? Application tables and shared lock storage remain, but all migrations will appear pending afterward.', $assocArgs); + $this->migrator->dropStore(); + WP_CLI::success('The migration ledger was dropped. Application tables were not changed, and shared lock storage remains available.'); + + return self::SUCCESS; + } + + if ($initialize) { + $this->migrator->initialize(); + WP_CLI::success('Foundation migration storage is initialized.'); + + return self::SUCCESS; + } + + if ($refresh) { + WP_CLI::confirm('Are you sure you want to roll back and rerun all Foundation database migrations?', $assocArgs); + $result = $this->migrator->refresh(); + WP_CLI::success(sprintf('Rolled back %d migrations and ran %d migrations.', count($result->rolledBack), count($result->ran))); + + return self::SUCCESS; + } + + if ($rollback) { + $result = $this->migrator->rollback(); + WP_CLI::success(sprintf('Rolled back %d migrations.', count($result->rolledBack))); + + return self::SUCCESS; + } + + if ($run) { + $result = $this->migrator->run(); + WP_CLI::success(sprintf('Ran %d migrations.', count($result->ran))); + + return self::SUCCESS; + } + + $this->showStatus(); + + return self::SUCCESS; + } + + protected function subcommand(): string { + return 'migrate'; + } + + protected function description(): string { + return 'List and manage database migrations.'; + } + + protected function arguments(): array { + return [ + [ + 'type' => self::FLAG, + 'name' => self::FLAG_RUN, + 'description' => 'Run pending migrations.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => self::FLAG, + 'name' => self::FLAG_ROLLBACK, + 'description' => 'Rollback the latest migration batch.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => self::FLAG, + 'name' => self::FLAG_REFRESH, + 'description' => 'Rollback and rerun all migrations.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => self::FLAG, + 'name' => self::FLAG_DROP_STORE, + 'description' => 'Drop only the migration ledger.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => self::FLAG, + 'name' => self::FLAG_INITIALIZE, + 'description' => 'Initialize or reconcile Foundation migration storage.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => self::FLAG, + 'name' => self::FLAG_YES, + 'description' => 'Skip confirmation prompts for destructive actions.', + 'optional' => true, + 'default' => false, + ], + ]; + } + + private function showStatus(): void { + if (! $this->migrator->isInitialized()) { + WP_CLI::warning(sprintf( + 'Migration storage is not initialized. Run `wp %s --initialize` first.', + $this->command() + )); + } + + format_items('table', array_map( + static fn ($status): array => [ + 'migration' => $status->migration, + 'status' => ! $status->available ? 'unavailable' : ($status->ran ? 'ran' : 'pending'), + 'batch' => $status->batch ?? '', + 'ran_at' => $status->ranAt?->format('Y-m-d H:i:s') ?? '', + ], + $this->migrator->status() + ), [ + 'migration', + 'status', + 'batch', + 'ran_at', + ]); + } + + /** + * @param array $operations + */ + private function assertSingleOperation(array $operations): void { + $selected = array_keys(array_filter($operations)); + + if (count($selected) <= 1) { + return; + } + + WP_CLI::error(sprintf( + 'Only one migration operation can be used at a time. Received: --%s.', + implode(', --', $selected) + )); + } +} diff --git a/src/Database/Contracts/Database.php b/src/Database/Contracts/Database.php new file mode 100644 index 0000000..fdac7b3 --- /dev/null +++ b/src/Database/Contracts/Database.php @@ -0,0 +1,107 @@ +|null + */ + public function row(string $sql, mixed ...$bindings): ?array; + + /** + * @throws QueryException When the query fails. + * + * @return list> + */ + public function rows(string $sql, mixed ...$bindings): array; + + /** + * @throws QueryException When the query fails. + */ + public function value(string $sql, mixed ...$bindings): mixed; + + /** + * @throws QueryException When the statement fails. + */ + public function execute(string $sql, mixed ...$bindings): int; + + /** + * @param array $data + * + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + * @throws QueryException When the insert fails. + * + * @return int Number of inserted rows. + */ + public function insert(Table|string $table, array $data): int; + + /** + * Insert a row and return its auto-increment identifier. + * + * @param array $data + * + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + * @throws QueryException When the insert fails. + */ + public function insertGetId(Table|string $table, array $data): int; + + /** + * @param array $data + * @param array $where + * + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + * @throws QueryException When the update fails. + */ + public function update(Table|string $table, array $data, array $where): int; + + /** + * @param array $where + * + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + * @throws QueryException When the delete fails. + */ + public function delete(Table|string $table, array $where): int; + + public function quoteIdentifier(string $identifier): string; + + public function escLike(string $value): string; + + public function charsetCollate(): string; +} diff --git a/src/Database/Contracts/Migration.php b/src/Database/Contracts/Migration.php new file mode 100644 index 0000000..3478742 --- /dev/null +++ b/src/Database/Contracts/Migration.php @@ -0,0 +1,25 @@ + + */ + public function all(): array; + + /** + * @throws InvalidMigrationId When the migration identifier is invalid. + */ + public function hasRun(string $migration): bool; + + /** + * @throws InvalidMigrationId When the migration identifier is invalid. + * @throws LedgerFailure When the inserted ledger record cannot be read back. + */ + public function recordRun(string $migration, int $batch): Record; + + /** + * Return false when no matching ledger row was deleted. + * + * @throws InvalidMigrationId When the migration identifier is invalid. + */ + public function deleteRun(string $migration): bool; + + public function nextBatch(): int; + + public function latestBatch(): ?int; + + /** + * @throws InvalidMigrationId When a stored migration identifier is invalid. + * + * @return list + */ + public function recordsForBatch(int $batch): array; +} diff --git a/src/Database/Contracts/Schema.php b/src/Database/Contracts/Schema.php new file mode 100644 index 0000000..6fa7abc --- /dev/null +++ b/src/Database/Contracts/Schema.php @@ -0,0 +1,52 @@ +name(); + } else { + $tableName = str_starts_with($table, $this->wpdb->prefix) ? $table : $this->wpdb->prefix . $table; + } + + $length = preg_match_all('/./us', $tableName); + + if (($length === false ? strlen($tableName) : $length) > 64) { + throw new DatabaseException(sprintf('Database table name "%s" exceeds MySQL\'s 64-character identifier limit.', $tableName)); + } + + return $tableName; + } + + public function tableExists(Table|string $table): bool { + $tableName = $this->tableName($table); + + return $this->row( + 'SHOW TABLES LIKE %s', + $this->escLike($tableName) + ) !== null; + } + + public function columnExists(Table|string $table, string $column): bool { + return $this->row( + 'SHOW COLUMNS FROM %i LIKE %s', + $this->tableName($table), + $this->escLike($column) + ) !== null; + } + + public function indexExists(Table|string $table, string $index): bool { + return $this->row( + 'SHOW INDEX FROM %i WHERE Key_name = %s', + $this->tableName($table), + $index + ) !== null; + } + + public function prepare(string $sql, mixed ...$bindings): string { + if (trim($sql) === '') { + throw new QueryException('SQL statement cannot be empty.', $sql, array_values($bindings)); + } + + if ($bindings === []) { + return $sql; + } + + $bindings = array_values($bindings); + $prepared = $this->prepareWithWpdb($sql, $bindings); + + if (! is_string($prepared) || $prepared === '') { + throw new QueryException('Unable to prepare SQL statement.', $sql, $bindings, $this->lastError()); + } + + return $prepared; + } + + /** + * @return array|null + */ + public function row(string $sql, mixed ...$bindings): ?array { + $bindings = array_values($bindings); + $query = $this->prepare($sql, ...$bindings); + $result = $this->wpdb->get_row($query, self::ARRAY_A); + $this->throwIfLastError($sql, $bindings); + + if ($result === null) { + return null; + } + + return $this->stringKeyedRow($result, $sql, $bindings); + } + + /** + * @return list> + */ + public function rows(string $sql, mixed ...$bindings): array { + $bindings = array_values($bindings); + $query = $this->prepare($sql, ...$bindings); + $results = $this->wpdb->get_results($query, self::ARRAY_A); + $this->throwIfLastError($sql, $bindings); + + if ($results === null) { + throw new QueryException('Unable to retrieve database rows.', $sql, $bindings); + } + + $rows = []; + + foreach ($results as $result) { + $rows[] = $this->stringKeyedRow($result, $sql, $bindings); + } + + return $rows; + } + + public function value(string $sql, mixed ...$bindings): mixed { + $bindings = array_values($bindings); + $query = $this->prepare($sql, ...$bindings); + $result = $this->wpdb->get_var($query); + $this->throwIfLastError($sql, $bindings); + + return $result; + } + + public function execute(string $sql, mixed ...$bindings): int { + $bindings = array_values($bindings); + $query = $this->prepare($sql, ...$bindings); + $result = $this->wpdb->query($query); + + if ($result === false) { + throw new QueryException($this->message('Unable to execute SQL statement.'), $sql, $bindings, $this->lastError()); + } + + return (int) $result; + } + + /** + * @param array $data + * + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + * @throws QueryException When the insert fails. + */ + public function insert(Table|string $table, array $data): int { + $result = $this->wpdb->insert($this->tableName($table), $data); + + if ($result === false) { + throw new QueryException($this->message('Unable to insert database row.'), 'INSERT', [], $this->lastError()); + } + + return (int) $result; + } + + /** + * @param array $data + * + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + * @throws QueryException When the insert fails. + */ + public function insertGetId(Table|string $table, array $data): int { + $this->insert($table, $data); + + return (int) $this->wpdb->insert_id; + } + + /** + * @param array $data + * @param array $where + * + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + * @throws QueryException When the update fails. + */ + public function update(Table|string $table, array $data, array $where): int { + $result = $this->wpdb->update($this->tableName($table), $data, $where); + + if ($result === false) { + throw new QueryException($this->message('Unable to update database rows.'), 'UPDATE', [], $this->lastError()); + } + + return (int) $result; + } + + /** + * @param array $where + * + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + * @throws QueryException When the delete fails. + */ + public function delete(Table|string $table, array $where): int { + $result = $this->wpdb->delete($this->tableName($table), $where); + + if ($result === false) { + throw new QueryException($this->message('Unable to delete database rows.'), 'DELETE', [], $this->lastError()); + } + + return (int) $result; + } + + public function quoteIdentifier(string $identifier): string { + return '`' . str_replace('`', '``', $identifier) . '`'; + } + + public function escLike(string $value): string { + return $this->wpdb->esc_like($value); + } + + public function charsetCollate(): string { + return $this->wpdb->get_charset_collate(); + } + + /** + * @param list $bindings + */ + private function throwIfLastError(string $sql, array $bindings): void { + $error = $this->lastError(); + + if ($error !== null) { + throw new QueryException($error, $sql, $bindings, $error); + } + } + + private function message(string $fallback): string { + return $this->lastError() ?? $fallback; + } + + private function lastError(): ?string { + $error = $this->wpdb->last_error; + + return $error !== '' ? $error : null; + } + + /** + * @param list $bindings + */ + private function prepareWithWpdb(string $sql, array $bindings): mixed { + $method = 'prepare'; + + return call_user_func_array([$this->wpdb, $method], array_merge([$sql], $bindings)); + } + + /** + * @param array $result + * @param list $bindings + * + * @return array + */ + private function stringKeyedRow(array $result, string $sql, array $bindings): array { + $row = []; + + foreach ($result as $key => $value) { + if (! is_string($key)) { + throw new DatabaseException('Database row result contained a non-string key.'); + } + + $row[$key] = $value; + } + + return $row; + } +} diff --git a/src/Database/DatabaseProvider.php b/src/Database/DatabaseProvider.php new file mode 100644 index 0000000..359f4c1 --- /dev/null +++ b/src/Database/DatabaseProvider.php @@ -0,0 +1,152 @@ +registerConfiguration(); + $this->registerDatabase(); + $this->registerTables(); + $this->registerMigrations(); + $this->registerLocks(); + $this->registerCliCommands(); + } + + private function registerConfiguration(): void { + $foundationPrefix = $this->foundationPrefix(); + $databasePrefix = str_replace('-', '_', $foundationPrefix); + $migrationsTable = $this->tableName( + $this->config->get('database.migrations_table'), + $databasePrefix . '_foundation_migrations' + ); + $locksTable = $this->tableName( + $this->config->get('database.locks_table'), + $databasePrefix . '_foundation_locks' + ); + $lockName = $this->config->get('database.lock_name') + ?? $foundationPrefix . '-foundation-database-migrations'; + + $this->container->mergeArrayVar(self::MIGRATIONS, []); + $this->container->singleton(self::MIGRATIONS_TABLE, $migrationsTable); + $this->container->singleton(self::LOCKS_TABLE, $locksTable); + $this->container->singleton(self::LOCK_NAME, $lockName); + $this->container->singleton(self::LOCK_TTL, (int) $this->config->get('database.lock_ttl', 300)); + } + + private function registerDatabase(): void { + $this->container->singleton(Database::class, static function (): Database { + $wpdb = $GLOBALS['wpdb'] ?? null; + + if (! $wpdb instanceof \wpdb) { + throw new DatabaseException('The global wpdb instance is not available.'); + } + + return new Database($wpdb); + }); + $this->container->singleton(DatabaseContract::class, static fn (C $c): Database => $c->get(Database::class)); + $this->container->singleton(DbDelta::class); + $this->container->singleton(SchemaExecutor::class, static fn (C $c): DbDelta => $c->get(DbDelta::class)); + $this->container->singleton(Reconciler::class); + $this->container->singleton(Schema::class); + $this->container->singleton(SchemaContract::class, static fn (C $c): Schema => $c->get(Schema::class)); + } + + private function registerTables(): void { + $this->container->when(MigrationTable::class) + ->needs('$table') + ->give(static fn (C $c): string => $c->get(self::MIGRATIONS_TABLE)); + + $this->container->when(LockTable::class) + ->needs('$table') + ->give(static fn (C $c): string => $c->get(self::LOCKS_TABLE)); + + $this->container->singleton(MigrationTable::class); + $this->container->singleton(LockTable::class); + } + + private function registerMigrations(): void { + $this->container->when(MigrationCollection::class) + ->needs('$migrations') + ->give(static fn (C $c): iterable => $c->get(self::MIGRATIONS)); + + $this->container->when(MigrationRecordRepository::class) + ->needs('$table') + ->give(static fn (C $c): string => $c->get(self::MIGRATIONS_TABLE)); + + $this->container->when(Store::class) + ->needs('$lockName') + ->give(static fn (C $c): string => $c->get(self::LOCK_NAME)); + + $this->container->when(Store::class) + ->needs('$lockTtl') + ->give(static fn (C $c): int => $c->get(self::LOCK_TTL)); + + $this->container->when(Store::class) + ->needs(Lock::class) + ->give(static fn (C $c): DatabaseLock => $c->get(DatabaseLock::class)); + + $this->container->singleton(MigrationCollection::class); + $this->container->singleton(MigrationRecordRepository::class); + $this->container->singleton(Repository::class, static fn (C $c): MigrationRecordRepository => $c->get(MigrationRecordRepository::class)); + $this->container->singleton(Migrator::class); + } + + private function registerLocks(): void { + $this->container->when(DatabaseLock::class) + ->needs('$table') + ->give(static fn (C $c): string => $c->get(self::LOCKS_TABLE)); + + $this->container->singleton(DatabaseLock::class); + } + + private function registerCliCommands(): void { + $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ + $c->get(Migrate::class), + ]); + } + + private function tableName(mixed $configured, string $default): mixed { + if (is_string($configured) && $configured !== '') { + return $configured; + } + + return static fn (C $c): string => $c->get(DatabaseContract::class)->tableName($default); + } +} diff --git a/src/Database/DatabaseStubPath.php b/src/Database/DatabaseStubPath.php new file mode 100644 index 0000000..43031cc --- /dev/null +++ b/src/Database/DatabaseStubPath.php @@ -0,0 +1,25 @@ + $bindings + */ + public function __construct( + string $message, + private readonly string $sql, + private readonly array $bindings = [], + private readonly ?string $databaseError = null, + ?Throwable $previous = null + ) { + parent::__construct($message, 0, $previous); + } + + public function sql(): string { + return $this->sql; + } + + /** + * @return list + */ + public function bindings(): array { + return $this->bindings; + } + + public function databaseError(): ?string { + return $this->databaseError; + } +} diff --git a/src/Database/Lock/DatabaseLock.php b/src/Database/Lock/DatabaseLock.php new file mode 100644 index 0000000..612bd90 --- /dev/null +++ b/src/Database/Lock/DatabaseLock.php @@ -0,0 +1,183 @@ +assertValidName($name); + $this->assertValidLockTtl($ttl); + + $owner = $this->generateLockOwner(); + + try { + $this->database->execute( + 'INSERT INTO %i (name, owner, expires_at, created_at, updated_at) + VALUES (%s, %s, TIMESTAMPADD(SECOND, %d, UTC_TIMESTAMP(6)), UTC_TIMESTAMP(6), UTC_TIMESTAMP(6)) + ON DUPLICATE KEY UPDATE + owner = IF(expires_at <= UTC_TIMESTAMP(6), %s, owner), + updated_at = IF(expires_at <= UTC_TIMESTAMP(6), UTC_TIMESTAMP(6), updated_at), + expires_at = IF( + expires_at <= UTC_TIMESTAMP(6), + TIMESTAMPADD(SECOND, %d, UTC_TIMESTAMP(6)), + expires_at + )', + $this->table, + $name, + $owner, + $ttl, + $owner, + $ttl + ); + + $row = $this->database->row( + 'SELECT expires_at FROM %i + WHERE name = %s AND owner = %s AND expires_at > UTC_TIMESTAMP(6) + LIMIT 1', + $this->table, + $name, + $owner + ); + } catch (DatabaseException $exception) { + throw new LockUnavailableException('The database could not determine the lock acquisition result.', 0, $exception); + } + + if ($row === null) { + return null; + } + + return new LockToken( + name: $name, + owner: $owner, + expiresAt: $this->expiration($row) + ); + } + + /** + * @throws LockUnavailableException When the database cannot determine the release result. + */ + public function release(LockToken $token): bool { + try { + return $this->database->execute( + 'DELETE FROM %i WHERE name = %s AND owner = %s AND expires_at > UTC_TIMESTAMP(6)', + $this->table, + $token->name, + $token->owner + ) > 0; + } catch (DatabaseException $exception) { + throw new LockUnavailableException('The database could not determine the lock release result.', 0, $exception); + } + } + + /** + * @throws InvalidArgumentException When the TTL is invalid. + * @throws LockUnavailableException When the database cannot determine the refresh result. + */ + public function refresh(LockToken $token, int $ttl): ?LockToken { + $this->assertValidLockTtl($ttl); + + try { + $this->database->execute( + 'UPDATE %i SET expires_at = TIMESTAMPADD(SECOND, %d, UTC_TIMESTAMP(6)), updated_at = UTC_TIMESTAMP(6) + WHERE name = %s AND owner = %s AND expires_at > UTC_TIMESTAMP(6)', + $this->table, + $ttl, + $token->name, + $token->owner + ); + + $row = $this->database->row( + 'SELECT expires_at FROM %i WHERE name = %s AND owner = %s AND expires_at > UTC_TIMESTAMP(6) LIMIT 1', + $this->table, + $token->name, + $token->owner + ); + } catch (DatabaseException $exception) { + throw new LockUnavailableException('The database could not determine the lock refresh result.', 0, $exception); + } + + if ($row === null) { + return null; + } + + return $token->withExpiration($this->expiration($row)); + } + + /** + * @throws InvalidArgumentException When the lock name is empty or exceeds 191 bytes. + * @throws LockUnavailableException When the database cannot determine whether the lock exists. + */ + public function isAcquired(string $name): bool { + $this->assertValidName($name); + + try { + return $this->database->row( + 'SELECT name FROM %i WHERE name = %s AND expires_at > UTC_TIMESTAMP(6) LIMIT 1', + $this->table, + $name + ) !== null; + } catch (DatabaseException $exception) { + throw new LockUnavailableException('The database could not determine whether the lock exists.', 0, $exception); + } + } + + /** + * @throws InvalidArgumentException When the lock name is empty or exceeds 191 bytes. + */ + private function assertValidName(string $name): void { + if (trim($name) === '') { + throw new InvalidArgumentException('Lock name cannot be empty.'); + } + + if (strlen($name) > 191) { + throw new InvalidArgumentException('A database lock name cannot exceed 191 bytes.'); + } + } + + /** + * @param array{expires_at?: mixed} $row + * + * @throws LockUnavailableException When the database returns an invalid expiration. + */ + private function expiration(array $row): DateTimeImmutable { + $expiration = $row['expires_at'] ?? null; + + if (! is_string($expiration) || $expiration === '') { + throw new LockUnavailableException('The database returned an invalid lock expiration.'); + } + + try { + return new DateTimeImmutable($expiration, new DateTimeZone('UTC')); + } catch (DateMalformedStringException $exception) { + throw new LockUnavailableException('The database returned an invalid lock expiration.', 0, $exception); + } + } +} diff --git a/src/Database/Migration/Collection.php b/src/Database/Migration/Collection.php new file mode 100644 index 0000000..4650170 --- /dev/null +++ b/src/Database/Migration/Collection.php @@ -0,0 +1,79 @@ + + */ +final class Collection implements IteratorAggregate +{ + /** + * @var array + */ + private array $migrations = []; + + /** + * @param iterable $migrations + * + * @throws DuplicateMigration When a migration identifier is already registered. + * @throws InvalidMigrationId When a migration identifier cannot be stored safely. + */ + public function __construct( + iterable $migrations = [] + ) { + foreach ($migrations as $migration) { + $this->add($migration); + } + } + + /** + * @throws DuplicateMigration When a migration identifier is already registered. + * @throws InvalidMigrationId When a migration identifier cannot be stored safely. + */ + public function add(Migration ...$migrations): void { + foreach ($migrations as $migration) { + $id = (new Id($migration->id()))->value; + + if (isset($this->migrations[$id])) { + throw DuplicateMigration::forMigration($id); + } + + $this->migrations[$id] = $migration; + } + } + + /** + * Return all migrations keyed by their byte-exact identifier. + * + * @return array + */ + public function all(): array { + return $this->migrations; + } + + /** + * Return all migrations as an ordered list. + * + * @return list + */ + public function values(): array { + return array_values($this->migrations); + } + + /** + * @return Traversable + */ + public function getIterator(): Traversable { + return new ArrayIterator($this->migrations); + } +} diff --git a/src/Database/Migration/Exceptions/InvalidMigrationId.php b/src/Database/Migration/Exceptions/InvalidMigrationId.php new file mode 100644 index 0000000..a4f9fa2 --- /dev/null +++ b/src/Database/Migration/Exceptions/InvalidMigrationId.php @@ -0,0 +1,12 @@ + $migrations + */ + public function __construct( + public readonly array $migrations + ) { + parent::__construct(sprintf( + 'Cannot roll back unavailable migrations: %s.', + implode(', ', $this->migrations) + )); + } +} diff --git a/src/Database/Migration/Exceptions/UninitializedStore.php b/src/Database/Migration/Exceptions/UninitializedStore.php new file mode 100644 index 0000000..7a663dd --- /dev/null +++ b/src/Database/Migration/Exceptions/UninitializedStore.php @@ -0,0 +1,15 @@ +store->initialize(); + } + + /** + * Drop the migration ledger while preserving shared lock storage. + * + * @throws DatabaseException When migration storage cannot be dropped. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. + * @throws UninitializedStore When migration storage has not been initialized. + */ + public function dropStore(): void { + $this->store->drop(); + } + + /** + * Determine whether the complete migration store has been initialized. + * + * @throws DatabaseException When migration storage cannot be inspected. + */ + public function isInitialized(): bool { + return $this->store->isInitialized(); + } + + /** + * Run all pending configured migrations. + * + * @throws DatabaseException When migration storage or schema access fails. + * @throws MigrationFailed When a migration fails while running. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. + * @throws UninitializedStore When migration storage has not been initialized. + */ + public function run(): Result { + $configured = $this->migrations->all(); + + return $this->store->withMigrationLock( + fn (Schema $schema): Result => $this->runPending($configured, $schema) + ); + } + + /** + * Roll back the latest recorded migration batch. + * + * @param int|null $batch The expected latest batch, available as Status::$batch from status(). Pass null to roll back whichever batch is latest. + * + * @throws DatabaseException When migration storage or schema access fails. + * @throws InvalidRollbackBatch When the requested batch does not match the latest recorded batch. + * @throws LedgerFailure When a rolled-back migration ledger record cannot be deleted. + * @throws MigrationFailed When a migration fails while rolling back. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. + * @throws UnavailableMigration When a recorded migration implementation is unavailable. + * @throws UninitializedStore When migration storage has not been initialized. + */ + public function rollback(?int $batch = null): Result { + $configured = $this->migrations->all(); + + return $this->store->withMigrationLock(function (Schema $schema) use ($configured, $batch): Result { + $latestBatch = $this->repository->latestBatch(); + + if ($batch !== null && $batch !== $latestBatch) { + throw new InvalidRollbackBatch($batch, $latestBatch); + } + + $batch ??= $latestBatch; + + if ($batch === null) { + return new Result(); + } + + return $this->rollbackRecords( + $configured, + $this->repository->recordsForBatch($batch), + $schema + ); + }); + } + + /** + * Roll back and rerun all configured migrations. + * + * @throws DatabaseException When migration storage or schema access fails. + * @throws MigrationFailed When a migration fails while running or rolling back. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. + * @throws UnavailableMigration When a recorded migration implementation is unavailable. + * @throws UninitializedStore When migration storage has not been initialized. + */ + public function refresh(): Result { + $configured = $this->migrations->all(); + + return $this->store->withMigrationLock(function (Schema $schema) use ($configured): Result { + $rollback = $this->rollbackRecords($configured, array_values($this->repository->all()), $schema); + $run = $this->runPending($configured, $schema); + + return new Result( + ran: $run->ran, + rolledBack: $rollback->rolledBack, + skipped: $run->skipped + ); + }); + } + + /** + * Return the status of every configured and recorded migration. + * + * @throws DatabaseException When migration storage cannot be inspected. + * + * @return list + */ + public function status(): array { + $configured = $this->migrations->all(); + + if (! $this->store->hasLedger()) { + return array_map( + static fn (Migration $migration): Status => Status::pending($migration->id()), + array_values($configured) + ); + } + + $records = $this->repository->all(); + $statuses = []; + + foreach ($configured as $migration) { + $statuses[] = isset($records[$migration->id()]) + ? Status::fromRecord($records[$migration->id()]) + : Status::pending($migration->id()); + + unset($records[$migration->id()]); + } + + foreach ($records as $record) { + $statuses[] = Status::unavailable($record); + } + + return $statuses; + } + + /** + * Roll back recorded migrations in reverse order after confirming every implementation is available. + * + * @param array $migrations + * @param list $records + * @param Schema $schema The initialized schema supplied by the migration store. + * + * @throws LedgerFailure When a rolled-back migration ledger record cannot be deleted. + * @throws UnavailableMigration When a recorded migration implementation is unavailable. + */ + private function rollbackRecords(array $migrations, array $records, Schema $schema): Result { + usort($records, static fn (Record $a, Record $b): int => $b->id <=> $a->id); + $unavailable = array_values(array_map( + static fn (Record $record): string => $record->migration, + array_filter($records, static fn (Record $record): bool => ! isset($migrations[$record->migration])) + )); + + if ($unavailable !== []) { + throw new UnavailableMigration($unavailable); + } + + $rolledBack = []; + + foreach ($records as $record) { + $migration = $migrations[$record->migration]; + + try { + $migration->down($schema); + } catch (Throwable $throwable) { + throw MigrationFailed::whileRollingBack($migration->id(), $throwable); + } + + if (! $this->repository->deleteRun($migration->id())) { + throw LedgerFailure::notDeletedAfterRollback($migration->id()); + } + + $rolledBack[] = $migration->id(); + } + + return new Result(rolledBack: $rolledBack); + } + + /** + * Run migrations that are absent from the ledger and record them in the next batch. + * + * @param array $migrations + * @param Schema $schema The initialized schema supplied by the migration store. + */ + private function runPending(array $migrations, Schema $schema): Result { + $ran = []; + $skipped = []; + $batch = $this->repository->nextBatch(); + + foreach ($migrations as $migration) { + if ($this->repository->hasRun($migration->id())) { + $skipped[] = $migration->id(); + continue; + } + + try { + $migration->up($schema); + } catch (Throwable $throwable) { + throw MigrationFailed::whileRunning($migration->id(), $throwable); + } + + $this->repository->recordRun($migration->id(), $batch); + $ran[] = $migration->id(); + } + + return new Result(ran: $ran, skipped: $skipped); + } +} diff --git a/src/Database/Migration/Repository.php b/src/Database/Migration/Repository.php new file mode 100644 index 0000000..c990e57 --- /dev/null +++ b/src/Database/Migration/Repository.php @@ -0,0 +1,148 @@ + + */ + public function all(): array { + $records = []; + + foreach ($this->database->rows(sprintf( + 'SELECT id, migration, batch, ran_at FROM %s ORDER BY id ASC', + $this->database->quoteIdentifier($this->table) + )) as $row) { + $record = $this->recordFromRow($row); + + $records[$record->migration] = $record; + } + + return $records; + } + + /** + * @throws InvalidMigrationId When the migration identifier is invalid. + */ + public function hasRun(string $migration): bool { + $migration = (new Id($migration))->value; + + return $this->database->row( + 'SELECT id FROM %i WHERE migration = %s LIMIT 1', + $this->table, + $migration + ) !== null; + } + + /** + * @throws InvalidMigrationId When the migration identifier is invalid. + * @throws LedgerFailure When the inserted ledger record cannot be read back. + */ + public function recordRun(string $migration, int $batch): Record { + $migration = (new Id($migration))->value; + $ranAt = new DateTimeImmutable('now', new DateTimeZone('UTC')); + + $this->database->execute( + 'INSERT INTO %i (migration, batch, ran_at) VALUES (%s, %d, %s)', + $this->table, + $migration, + $batch, + $ranAt->format('Y-m-d H:i:s') + ); + + $row = $this->database->row( + 'SELECT id, migration, batch, ran_at FROM %i WHERE migration = %s LIMIT 1', + $this->table, + $migration + ); + + if ($row === null) { + throw LedgerFailure::missingAfterInsert($migration); + } + + return $this->recordFromRow($row); + } + + /** + * @throws InvalidMigrationId When the migration identifier is invalid. + */ + public function deleteRun(string $migration): bool { + $migration = (new Id($migration))->value; + + return $this->database->execute( + 'DELETE FROM %i WHERE migration = %s', + $this->table, + $migration + ) > 0; + } + + public function nextBatch(): int { + $latest = $this->latestBatch(); + + return $latest === null ? 1 : $latest + 1; + } + + public function latestBatch(): ?int { + $row = $this->database->row(sprintf( + 'SELECT MAX(batch) AS batch FROM %s', + $this->database->quoteIdentifier($this->table) + )); + + if ($row === null || $row['batch'] === null) { + return null; + } + + return (int) $row['batch']; + } + + /** + * @throws InvalidMigrationId When a stored migration identifier is invalid. + * + * @return list + */ + public function recordsForBatch(int $batch): array { + return array_map( + fn (array $row): Record => $this->recordFromRow($row), + $this->database->rows( + 'SELECT id, migration, batch, ran_at FROM %i WHERE batch = %d ORDER BY id ASC', + $this->table, + $batch + ) + ); + } + + /** + * @param array $row + * + * @throws InvalidMigrationId When the stored migration identifier is invalid. + */ + private function recordFromRow(array $row): Record { + return new Record( + id: (int) $row['id'], + migration: (new Id((string) $row['migration']))->value, + batch: (int) $row['batch'], + ranAt: new DateTimeImmutable((string) $row['ran_at'], new DateTimeZone('UTC')) + ); + } +} diff --git a/src/Database/Migration/Store.php b/src/Database/Migration/Store.php new file mode 100644 index 0000000..7a3905c --- /dev/null +++ b/src/Database/Migration/Store.php @@ -0,0 +1,173 @@ +lockName) === '') { + throw new InvalidArgumentException('The migration lock name cannot be empty.'); + } + + if ($this->lockTtl < 1) { + throw new InvalidArgumentException('The migration lock TTL must be at least one second.'); + } + } + + /** + * Initialize or reconcile the complete migration store before migrations run. + * + * @throws DatabaseException When migration storage cannot be initialized. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. + */ + public function initialize(): void { + $this->schema->createOrUpdate($this->lockTable); + + $this->withLock(function (): void { + $this->schema->createOrUpdate($this->migrationTable); + }); + } + + /** + * Drop the migration ledger while preserving shared lock storage. + * + * @throws DatabaseException When the ledger cannot be dropped. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. + * @throws UninitializedStore When migration storage has not been initialized. + */ + public function drop(): void { + $this->withMigrationLock(function (Schema $schema): void { + $schema->drop($this->migrationTable); + }); + } + + /** + * Determine whether the migration subsystem storage is ready. + * + * @throws DatabaseException When migration storage cannot be inspected. + */ + public function isInitialized(): bool { + return $this->hasLedger() && $this->schema->hasTable($this->lockTable); + } + + /** + * Determine whether recorded migration state can be read. + * + * @throws DatabaseException When migration storage cannot be inspected. + */ + public function hasLedger(): bool { + return $this->schema->hasTable($this->migrationTable); + } + + /** + * Run an operation against initialized migration storage while holding its lock. + * + * @template T + * + * @param callable(Schema): T $operation The operation that receives the schema while the migration lock is held. + * + * @throws DatabaseException When migration storage cannot be inspected. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. + * @throws UninitializedStore When migration storage has not been initialized. + * + * @return T + */ + public function withMigrationLock(callable $operation): mixed { + // Migration lock storage must exist before lock acquisition. + if (! $this->schema->hasTable($this->lockTable)) { + throw new UninitializedStore(); + } + + return $this->withLock(function () use ($operation): mixed { + // The ledger may have changed before this process acquired the lock. + $this->assertInitialized(); + + return $operation($this->schema); + }); + } + + /** + * Reject migration operations until the internal store has been initialized. + * + * @throws DatabaseException When migration storage cannot be inspected. + * @throws UninitializedStore When migration storage has not been initialized. + */ + private function assertInitialized(): void { + if (! $this->isInitialized()) { + throw new UninitializedStore(); + } + } + + /** + * Run an operation while owning the configured migration lock and release it afterward. + * + * The lock table is the one bootstrap exception: it must exist before its own + * database-backed lock can be acquired. + * + * @template T + * + * @param callable(): T $operation + * + * @throws DatabaseException When migration storage access fails. + * @throws MigrationLockFailed When the lock cannot be acquired or ownership cannot be confirmed during release. + * @throws LockUnavailableException When the lock backend cannot determine the lock state. + * + * @return T + */ + private function withLock(callable $operation): mixed { + $token = $this->lock->acquire($this->lockName, $this->lockTtl); + + if ($token === null) { + throw MigrationLockFailed::forLock($this->lockName); + } + + try { + $result = $operation(); + } catch (Throwable $failure) { + try { + $this->lock->release($token); + } catch (Throwable) { + // Preserve the primary migration failure when cleanup also fails. + } + + throw $failure; + } + + if (! $this->lock->release($token)) { + throw MigrationLockFailed::forUnconfirmedOwnership($this->lockName); + } + + return $result; + } +} diff --git a/src/Database/Migration/ValueObjects/Id.php b/src/Database/Migration/ValueObjects/Id.php new file mode 100644 index 0000000..c87c5a8 --- /dev/null +++ b/src/Database/Migration/ValueObjects/Id.php @@ -0,0 +1,32 @@ +value === '' || trim($this->value) !== $this->value) { + throw new InvalidMigrationId('Migration IDs cannot be blank or contain surrounding whitespace.'); + } + + if (strlen($this->value) > self::MAX_BYTES) { + throw new InvalidMigrationId(sprintf('Migration IDs cannot exceed %d bytes.', self::MAX_BYTES)); + } + + if (preg_match('/^(?:0|-?[1-9][0-9]*)$/D', $this->value) === 1) { + throw new InvalidMigrationId('Migration IDs cannot be integer-like strings.'); + } + } +} diff --git a/src/Database/Migration/ValueObjects/Record.php b/src/Database/Migration/ValueObjects/Record.php new file mode 100644 index 0000000..d3a925b --- /dev/null +++ b/src/Database/Migration/ValueObjects/Record.php @@ -0,0 +1,19 @@ + $ran + * @param list $rolledBack + * @param list $skipped + */ + public function __construct( + public array $ran = [], + public array $rolledBack = [], + public array $skipped = [] + ) { + } + + public function count(): int { + return count($this->ran) + count($this->rolledBack); + } +} diff --git a/src/Database/Migration/ValueObjects/Status.php b/src/Database/Migration/ValueObjects/Status.php new file mode 100644 index 0000000..854973a --- /dev/null +++ b/src/Database/Migration/ValueObjects/Status.php @@ -0,0 +1,43 @@ +migration, + ran: true, + batch: $record->batch, + ranAt: $record->ranAt + ); + } + + public static function unavailable(Record $record): self { + return new self( + migration: $record->migration, + ran: true, + batch: $record->batch, + ranAt: $record->ranAt, + available: false + ); + } +} diff --git a/src/Database/Query/Query.php b/src/Database/Query/Query.php new file mode 100644 index 0000000..4278b3a --- /dev/null +++ b/src/Database/Query/Query.php @@ -0,0 +1,54 @@ + $bindings + */ + public function __construct( + private Database $database, + private string $sql, + private array $bindings = [] + ) { + } + + public function toSql(): string { + return $this->sql; + } + + /** + * @return list + */ + public function bindings(): array { + return $this->bindings; + } + + public function toPreparedSql(): string { + return $this->database->prepare($this->sql, ...$this->bindings); + } + + /** + * @return list> + */ + public function get(): array { + return $this->database->rows($this->sql, ...$this->bindings); + } + + /** + * @return array|null + */ + public function first(): ?array { + return $this->database->row($this->sql, ...$this->bindings); + } + + public function value(): mixed { + return $this->database->value($this->sql, ...$this->bindings); + } +} diff --git a/src/Database/Query/QueryBuilder.php b/src/Database/Query/QueryBuilder.php new file mode 100644 index 0000000..1f13db9 --- /dev/null +++ b/src/Database/Query/QueryBuilder.php @@ -0,0 +1,261 @@ + + */ + private array $columns = ['*']; + + /** + * @var list + */ + private array $where = []; + + /** + * @var list + */ + private array $bindings = []; + + /** + * @var list + */ + private array $orderBy = []; + + private ?int $limit = null; + + private ?int $offset = null; + + public function __construct( + private readonly Database $database, + private readonly Table|string $table, + private readonly ?string $alias = null + ) { + } + + public function select(string ...$columns): self { + $this->columns = $columns === [] ? ['*'] : array_values($columns); + + return $this; + } + + /** + * Compare a column to a value. NULL values use IS NULL or IS NOT NULL semantics. + * + * @throws InvalidArgumentException When the column or operator is invalid, or the operator cannot compare against NULL. + */ + public function where(string $column, string $operator, mixed $value): self { + $operator = $this->operator($operator); + + if ($value === null) { + if (! in_array($operator, ['=', '!=', '<>'], true)) { + throw new InvalidArgumentException('NULL comparisons only support =, !=, and <> operators.'); + } + + $this->where[] = sprintf( + '%s IS%s NULL', + $this->quoteColumn($column), + $operator === '=' ? '' : ' NOT' + ); + + return $this; + } + + $this->where[] = sprintf('%s %s %%s', $this->quoteColumn($column), $operator); + $this->bindings[] = $value; + + return $this; + } + + /** + * @throws InvalidArgumentException When the column or direction is invalid. + */ + public function orderBy(string $column, string $direction = 'ASC'): self { + $direction = strtoupper($direction); + + if (! in_array($direction, ['ASC', 'DESC'], true)) { + throw new InvalidArgumentException('Order direction must be ASC or DESC.'); + } + + $this->orderBy[] = sprintf('%s %s', $this->quoteColumn($column), $direction); + + return $this; + } + + /** + * @throws InvalidArgumentException When the limit or offset is invalid. + */ + public function limit(int $limit, ?int $offset = null): self { + if ($limit < 1) { + throw new InvalidArgumentException('Query limit must be greater than zero.'); + } + + if ($offset !== null && $offset < 0) { + throw new InvalidArgumentException('Query offset cannot be negative.'); + } + + $this->limit = $limit; + $this->offset = $offset; + + return $this; + } + + /** + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + * @throws InvalidArgumentException When a selected column is invalid. + */ + public function query(): Query { + return new Query($this->database, $this->toSql(), $this->bindings()); + } + + /** + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + * @throws InvalidArgumentException When a selected column is invalid. + */ + public function toSql(): string { + $sql = sprintf( + 'SELECT %s FROM %s%s', + $this->selectSql(), + $this->database->quoteIdentifier($this->database->tableName($this->table)), + $this->aliasSql() + ); + + if ($this->where !== []) { + $sql .= ' WHERE ' . implode(' AND ', $this->where); + } + + if ($this->orderBy !== []) { + $sql .= ' ORDER BY ' . implode(', ', $this->orderBy); + } + + if ($this->limit !== null) { + $sql .= ' LIMIT %d'; + + if ($this->offset !== null) { + $sql .= ' OFFSET %d'; + } + } + + return $sql; + } + + /** + * @return list + */ + public function bindings(): array { + $bindings = $this->bindings; + + if ($this->limit !== null) { + $bindings[] = $this->limit; + + if ($this->offset !== null) { + $bindings[] = $this->offset; + } + } + + return $bindings; + } + + /** + * @throws DatabaseException When table-name resolution or query preparation fails. + * @throws InvalidArgumentException When a selected column is invalid. + */ + public function toPreparedSql(): string { + return $this->database->prepare($this->toSql(), ...$this->bindings()); + } + + /** + * @throws DatabaseException When table-name resolution or query execution fails. + * @throws InvalidArgumentException When a selected column is invalid. + * + * @return list> + */ + public function get(): array { + return $this->queryWithLimitBindings()->get(); + } + + /** + * @throws DatabaseException When table-name resolution or query execution fails. + * @throws InvalidArgumentException When a selected column is invalid. + * + * @return array|null + */ + public function first(): ?array { + $query = clone $this; + $query->limit = 1; + + return $query->queryWithLimitBindings()->first(); + } + + /** + * @throws DatabaseException When the table name exceeds MySQL's identifier limit. + */ + private function queryWithLimitBindings(): Query { + return new Query($this->database, $this->toSql(), $this->bindings()); + } + + private function selectSql(): string { + return implode(', ', array_map(fn (string $column): string => $this->quoteColumn($column, true), $this->columns)); + } + + private function aliasSql(): string { + if ($this->alias === null || $this->alias === '') { + return ''; + } + + return ' AS ' . $this->database->quoteIdentifier($this->alias); + } + + private function operator(string $operator): string { + $operator = strtoupper(trim($operator)); + + if (! in_array($operator, ['=', '!=', '<>', '>', '>=', '<', '<=', 'LIKE'], true)) { + throw new InvalidArgumentException(sprintf('Unsupported query operator: %s.', $operator)); + } + + return $operator; + } + + /** + * Quote each segment of a qualified column reference. + * + * For example, p.ID becomes `p`.`ID`. When wildcards are allowed, + * p.* becomes `p`.*. + * + * @throws InvalidArgumentException When the column contains an empty segment or a disallowed wildcard. + */ + private function quoteColumn(string $column, bool $allowWildcard = false): string { + $segments = explode('.', $column); + $last = array_key_last($segments); + $quoted = []; + + foreach ($segments as $index => $segment) { + if ($segment === '') { + throw new InvalidArgumentException(sprintf('Invalid query column: %s.', $column)); + } + + if ($segment === '*') { + if (! $allowWildcard || $index !== $last) { + throw new InvalidArgumentException(sprintf('Invalid query column wildcard: %s.', $column)); + } + + $quoted[] = '*'; + continue; + } + + $quoted[] = $this->database->quoteIdentifier($segment); + } + + return implode('.', $quoted); + } +} diff --git a/src/Database/README.md b/src/Database/README.md new file mode 100644 index 0000000..de83ed1 --- /dev/null +++ b/src/Database/README.md @@ -0,0 +1,19 @@ +# Foundation Database + +> [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +Foundation Database provides WordPress-backed table definitions, versioned +migrations, a small query API, database-backed locks, and a WP-CLI deployment +workflow built on `wpdb` and `dbDelta()`. + +## Installation + +```shell +composer require stellarwp/foundation-database +``` + +## Documentation + +See the [Foundation Database documentation](https://foundation.nexcess.dev/components/database/) +for configuration, migrations, query building, database locks, and testing. diff --git a/src/Database/Schema.php b/src/Database/Schema.php new file mode 100644 index 0000000..8655981 --- /dev/null +++ b/src/Database/Schema.php @@ -0,0 +1,73 @@ +reconciler->reconcile($table); + } + + public function execute(string $sql): void { + $this->database->execute($sql); + } + + /** + * @throws DatabaseException When table inspection fails. + */ + public function hasTable(Table|string $table): bool { + return $this->database->tableExists($table); + } + + /** + * @throws DatabaseException When index inspection fails. + */ + public function hasIndex(Table|string $table, string $index): bool { + return $this->database->indexExists($table, $index); + } + + /** + * @throws DatabaseException When the table name is invalid or the statement cannot be executed. + */ + public function dropIndex(Table|string $table, string $index): void { + $this->database->execute(sprintf( + 'ALTER TABLE %s DROP INDEX %s', + $this->database->quoteIdentifier($this->database->tableName($table)), + $this->database->quoteIdentifier($index) + )); + } + + /** + * @throws DatabaseException When the table name is invalid or the statement cannot be executed. + */ + public function drop(Table|string $table): void { + $this->database->execute(sprintf( + 'DROP TABLE IF EXISTS %s', + $this->database->quoteIdentifier($this->database->tableName($table)) + )); + } + + public function quoteIdentifier(string $identifier): string { + return $this->database->quoteIdentifier($identifier); + } +} diff --git a/src/Database/Schema/DbDelta.php b/src/Database/Schema/DbDelta.php new file mode 100644 index 0000000..7c67f9b --- /dev/null +++ b/src/Database/Schema/DbDelta.php @@ -0,0 +1,75 @@ +last_error !== '') { + throw new QueryException($wpdb->last_error, $sql, [], $wpdb->last_error); + } + + $pending = dbDelta($sql, false); + $pending = array_filter( + $pending, + fn (string $change): bool => ! $this->createdTableExists($change, $wpdb) + ); + + if ($pending !== []) { + throw new DatabaseException(sprintf( + 'Database schema reconciliation did not complete: %s', + implode('; ', $pending) + )); + } + } + + /** + * Ignore WordPress 6.2's stale dry-run result for a table that was created successfully. + * + * @throws QueryException When WordPress cannot verify the table. + */ + private function createdTableExists(string $change, \wpdb $wpdb): bool { + $prefix = 'Created table '; + + if (! str_starts_with($change, $prefix)) { + return false; + } + + $table = trim(substr($change, strlen($prefix)), '`'); + $query = $wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)); + $found = $wpdb->get_var($query); + + if ($wpdb->last_error !== '') { + throw new QueryException($wpdb->last_error, 'SHOW TABLES LIKE %s', [$table], $wpdb->last_error); + } + + return $found === $table; + } +} diff --git a/src/Database/Schema/Reconciler.php b/src/Database/Schema/Reconciler.php new file mode 100644 index 0000000..638cfff --- /dev/null +++ b/src/Database/Schema/Reconciler.php @@ -0,0 +1,237 @@ +definition(); + $definition->assertValid(); + + $this->executor->execute($this->createTableSql($table, $definition)); + $this->reconcileComplexDefaults($table, $definition); + $this->assertColumnPropertiesMatch($table, $definition); + } + + /** + * Build the CREATE TABLE statement passed to the WordPress schema executor. + * + * @throws DatabaseException When the physical table name is invalid. + */ + private function createTableSql(Table $table, TableDefinition $definition): string { + $parts = []; + + foreach ($definition->columns() as $column) { + $parts[] = ' ' . $column->sql(); + } + + foreach ($definition->indexes() as $index) { + $parts[] = ' ' . $index->sql(); + } + + return sprintf( + "CREATE TABLE %s (\n%s\n) %s;", + $this->database->quoteIdentifier($this->database->tableName($table)), + implode(",\n", $parts), + $this->database->charsetCollate() + ); + } + + /** + * Reconcile binary string defaults that dbDelta cannot represent safely. + * + * @throws DatabaseException When a default cannot be reconciled. + */ + private function reconcileComplexDefaults(Table $table, TableDefinition $definition): void { + foreach ($definition->columns() as $column) { + $default = $column->defaultSql(); + + if ($default === null || ! str_starts_with($default, "X'")) { + continue; + } + + $this->database->execute(sprintf( + 'ALTER TABLE %s ALTER COLUMN %s SET DEFAULT %s', + $this->database->quoteIdentifier($this->database->tableName($table)), + $this->database->quoteIdentifier($column->name), + $default + )); + } + } + + /** + * Verify properties that dbDelta does not reliably reconcile. + * + * @throws DatabaseException When column metadata is missing, invalid, or differs from the definition. + */ + private function assertColumnPropertiesMatch(Table $table, TableDefinition $definition): void { + $differences = []; + + foreach ($definition->columns() as $column) { + $properties = $this->columnProperties($table, $column); + + if ($properties['nullable'] !== $column->nullable) { + $differences[] = sprintf( + 'column %s expected %s, found %s', + $column->name, + $column->nullable ? 'NULL' : 'NOT NULL', + $properties['nullable'] ? 'NULL' : 'NOT NULL' + ); + } + + if (! $this->defaultMatches($column, $properties['default'])) { + $differences[] = sprintf( + 'column %s expected %s, found %s', + $column->name, + $column->defaultSql() === null ? 'no default' : 'DEFAULT ' . $column->defaultSql(), + $properties['default'] === null ? 'DEFAULT NULL' : 'DEFAULT ' . (string) $properties['default'] + ); + } + + $expectedExtra = $this->normalizeExtra($column->extra); + $actualExtra = $this->normalizeExtra($properties['extra']); + + if ($expectedExtra !== $actualExtra) { + $differences[] = sprintf( + 'column %s expected extra %s, found %s', + $column->name, + $expectedExtra === '' ? 'none' : $expectedExtra, + $actualExtra === '' ? 'none' : $actualExtra + ); + } + } + + if ($differences !== []) { + throw new DatabaseException(sprintf( + 'Database schema reconciliation did not apply the definition for %s: %s.', + $this->database->tableName($table), + implode('; ', $differences) + )); + } + } + + /** + * Read and validate the database metadata used to verify a column definition. + * + * @throws DatabaseException When column metadata is missing or invalid. + * + * @return array{nullable: bool, default: mixed, extra: string} + */ + private function columnProperties(Table $table, Column $column): array { + $row = $this->database->row( + 'SHOW FULL COLUMNS FROM %i WHERE Field = %s', + $this->database->tableName($table), + $column->name + ); + + if ($row === null) { + throw new DatabaseException(sprintf( + 'Database schema reconciliation could not inspect %s.%s.', + $this->database->tableName($table), + $column->name + )); + } + + $nullable = $row['Null'] ?? null; + $extra = $row['Extra'] ?? null; + + if ( + ! is_string($nullable) + || ! in_array(strtoupper($nullable), ['YES', 'NO'], true) + || ! array_key_exists('Default', $row) + || ! is_string($extra) + ) { + throw new DatabaseException(sprintf( + 'Database returned invalid column metadata for %s.%s.', + $this->database->tableName($table), + $column->name + )); + } + + return [ + 'nullable' => strtoupper($nullable) === 'YES', + 'default' => $row['Default'], + 'extra' => $extra, + ]; + } + + /** + * Determine whether a database-reported default matches the declared column default. + */ + private function defaultMatches(Column $column, mixed $actual): bool { + if ($column->default === null) { + return $actual === null; + } + + if ($actual === null) { + return false; + } + + if (is_bool($column->default)) { + return $this->integerDefaultMatches($column->default ? 1 : 0, $actual); + } + + if (is_int($column->default)) { + return $this->integerDefaultMatches($column->default, $actual); + } + + if (is_float($column->default)) { + return is_numeric($actual) && (float) $actual === $column->default; + } + + return (string) $actual === (string) $column->default; + } + + /** + * Compare an integer default with MySQL integer, decimal, or bit-literal metadata. + */ + private function integerDefaultMatches(int $expected, mixed $actual): bool { + $actual = (string) $actual; + + if (preg_match("/^b'([01]+)'$/i", $actual, $bits) === 1) { + return bindec($bits[1]) === $expected; + } + + if (preg_match('/^([+-]?\d+)(?:\.0+)?$/', $actual, $integer) !== 1) { + return false; + } + + return filter_var($integer[1], FILTER_VALIDATE_INT) === $expected; + } + + /** + * Normalize equivalent MySQL Extra metadata before comparing column definitions. + */ + private function normalizeExtra(string $extra): string { + $extra = str_ireplace('DEFAULT_GENERATED', '', $extra); + $extra = preg_replace('/CURRENT_TIMESTAMP\(\)/i', 'CURRENT_TIMESTAMP', $extra) ?? $extra; + + return strtolower(trim(preg_replace('/\s+/', ' ', $extra) ?? $extra)); + } +} diff --git a/src/Database/Table/Column.php b/src/Database/Table/Column.php new file mode 100644 index 0000000..a414667 --- /dev/null +++ b/src/Database/Table/Column.php @@ -0,0 +1,137 @@ +name), + $this->type, + $this->length === null ? '' : sprintf('(%d)', $this->length), + $this->unsigned ? ' unsigned' : '', + $this->nullable ? ' NULL' : ' NOT NULL' + ); + + $default = $this->defaultSql(); + + if ($default !== null) { + $sql .= sprintf(' DEFAULT %s', $default); + } + + if ($this->extra !== '') { + $sql .= ' ' . $this->extra; + } + + return $sql; + } + + /** + * Return the SQL literal for an explicit default value. + */ + public function defaultSql(): ?string { + if ($this->default === null && ! $this->hasDefault) { + return null; + } + + return $this->formatDefault($this->default); + } + + public function unsigned(bool $unsigned = true): self { + return new self( + $this->name, + $this->type, + $this->length, + $unsigned, + $this->nullable, + $this->default, + $this->extra, + $this->hasDefault + ); + } + + public function nullable(bool $nullable = true): self { + return new self( + $this->name, + $this->type, + $this->length, + $this->unsigned, + $nullable, + $this->default, + $this->extra, + $this->hasDefault + ); + } + + public function default(mixed $default): self { + return new self( + $this->name, + $this->type, + $this->length, + $this->unsigned, + $default === null ? true : $this->nullable, + $default, + $this->extra, + true + ); + } + + public function extra(string $extra): self { + return new self( + $this->name, + $this->type, + $this->length, + $this->unsigned, + $this->nullable, + $this->default, + $extra, + $this->hasDefault + ); + } + + public function autoIncrement(): self { + if (preg_match('/(?:^|\s)AUTO_INCREMENT(?:\s|$)/i', $this->extra) === 1) { + return $this; + } + + return $this->extra(trim($this->extra . ' AUTO_INCREMENT')); + } + + private function formatDefault(mixed $default): string { + if ($default === null) { + return 'NULL'; + } + + if (is_bool($default)) { + return $default ? '1' : '0'; + } + + if (is_int($default) || is_float($default)) { + return (string) $default; + } + + $default = (string) $default; + + if (preg_match("/['\\\\\x00-\x1F\x7F]/", $default) === 1) { + return "X'" . bin2hex($default) . "'"; + } + + return "'" . $default . "'"; + } +} diff --git a/src/Database/Table/CreateTable.php b/src/Database/Table/CreateTable.php new file mode 100644 index 0000000..1e8e225 --- /dev/null +++ b/src/Database/Table/CreateTable.php @@ -0,0 +1,30 @@ +table->id(); + } + + public function up(Schema $schema): void { + $schema->createOrUpdate($this->table); + } + + public function down(Schema $schema): void { + $schema->drop($this->table); + } +} diff --git a/src/Database/Table/Index.php b/src/Database/Table/Index.php new file mode 100644 index 0000000..ed8a97f --- /dev/null +++ b/src/Database/Table/Index.php @@ -0,0 +1,36 @@ + $columns + */ + public function __construct( + public string $name, + public array $columns, + public string $type = IndexType::KEY + ) { + } + + public function sql(): string { + $columns = implode(', ', array_map( + static fn (string $column): string => '`' . str_replace('`', '``', $column) . '`', + $this->columns + )); + + return match ($this->type) { + IndexType::PRIMARY => sprintf('PRIMARY KEY (%s)', $columns), + IndexType::UNIQUE => sprintf('UNIQUE KEY %s (%s)', $this->quotedName(), $columns), + default => sprintf('KEY %s (%s)', $this->quotedName(), $columns), + }; + } + + private function quotedName(): string { + return '`' . str_replace('`', '``', $this->name) . '`'; + } +} diff --git a/src/Database/Table/IndexType.php b/src/Database/Table/IndexType.php new file mode 100644 index 0000000..88d816a --- /dev/null +++ b/src/Database/Table/IndexType.php @@ -0,0 +1,16 @@ + + */ + private array $columns = []; + + /** + * @var list + */ + private array $indexes = []; + + private ?string $currentColumn = null; + + private function __construct( + private readonly Table $table + ) { + } + + public static function for(Table $table): self { + return new self($table); + } + + public function bigIncrements(string $name): self { + return $this + ->column(new Column($name, 'bigint', 20)) + ->unsigned() + ->autoIncrement() + ->primary($name); + } + + public function string(string $name, int $length = 191, ?string $default = null): self { + return $this->column(new Column($name, 'varchar', $length, default: $default)); + } + + public function unsignedInteger(string $name, int $length = 10, ?int $default = null): self { + return $this->column(new Column($name, 'int', $length, unsigned: true, default: $default)); + } + + public function integer(string $name, int $length = 10): self { + return $this->column(new Column($name, 'int', $length)); + } + + public function tinyInteger(string $name, int $length = 3): self { + return $this->column(new Column($name, 'tinyint', $length)); + } + + public function bigInteger(string $name, int $length = 20): self { + return $this->column(new Column($name, 'bigint', $length)); + } + + /** + * @throws InvalidArgumentException When precision is outside the database-supported range. + */ + public function dateTime(string $name, ?int $precision = null): self { + if ($precision !== null && ($precision < 0 || $precision > 6)) { + throw new InvalidArgumentException('Datetime precision must be between 0 and 6.'); + } + + return $this->column(new Column($name, 'datetime', $precision === 0 ? null : $precision)); + } + + public function text(string $name): self { + return $this->column(new Column($name, 'text')); + } + + public function longText(string $name): self { + return $this->column(new Column($name, 'longtext')); + } + + public function column(Column $column): self { + $this->columns[$column->name] = $column; + $this->currentColumn = $column->name; + + return $this; + } + + public function unsigned(bool $unsigned = true): self { + return $this->replaceCurrentColumn($this->currentColumn()->unsigned($unsigned)); + } + + public function nullable(bool $nullable = true): self { + return $this->replaceCurrentColumn($this->currentColumn()->nullable($nullable)); + } + + public function notNull(): self { + return $this->nullable(false); + } + + public function default(mixed $default): self { + return $this->replaceCurrentColumn($this->currentColumn()->default($default)); + } + + public function autoIncrement(): self { + return $this->replaceCurrentColumn($this->currentColumn()->autoIncrement()); + } + + public function extra(string $extra): self { + return $this->replaceCurrentColumn($this->currentColumn()->extra($extra)); + } + + private function replaceCurrentColumn(Column $column): self { + $this->columns[$column->name] = $column; + + return $this; + } + + public function primary(string ...$columns): self { + $this->indexes[] = new Index('primary', $this->nonEmptyColumns(array_values($columns)), IndexType::PRIMARY); + $this->currentColumn = null; + + return $this; + } + + public function unique(string $name, string ...$columns): self { + $this->indexes[] = new Index($name, $this->nonEmptyColumns(array_values($columns)), IndexType::UNIQUE); + $this->currentColumn = null; + + return $this; + } + + public function index(string $name, string ...$columns): self { + $this->indexes[] = new Index($name, $this->nonEmptyColumns(array_values($columns)), IndexType::KEY); + $this->currentColumn = null; + + return $this; + } + + /** + * @return list + */ + public function columns(): array { + return array_values($this->columns); + } + + /** + * @return list + */ + public function indexes(): array { + return $this->indexes; + } + + /** + * @return list + */ + public function validationErrors(): array { + $errors = []; + + if ($this->columns === []) { + $errors[] = sprintf('Table %s does not define any columns.', $this->table->id()); + } + + foreach ($this->indexes as $index) { + if ($index->type === IndexType::PRIMARY) { + continue; + } + + foreach ($this->indexesByName($index->name) as $duplicate) { + if ($duplicate !== $index && $duplicate->type !== IndexType::PRIMARY) { + $errors[] = sprintf('Index %s is defined more than once.', $index->name); + break 2; + } + } + } + + if (count(array_filter($this->indexes, static fn (Index $index): bool => $index->type === IndexType::PRIMARY)) > 1) { + $errors[] = 'A table can define only one primary key.'; + } + + foreach ($this->indexes as $index) { + foreach ($index->columns as $column) { + if (! isset($this->columns[$column])) { + $errors[] = sprintf('Index %s references missing column %s.', $index->name, $column); + } + } + } + + return $errors; + } + + public function assertValid(): void { + $errors = $this->validationErrors(); + + if ($errors !== []) { + throw new InvalidArgumentException(implode(' ', $errors)); + } + } + + /** + * @param list $columns + * + * @return non-empty-list + */ + private function nonEmptyColumns(array $columns): array { + if ($columns === []) { + throw new InvalidArgumentException('An index must define at least one column.'); + } + + return $columns; + } + + private function currentColumn(): Column { + if ($this->currentColumn === null || ! isset($this->columns[$this->currentColumn])) { + throw new InvalidArgumentException('A column modifier must follow a column definition.'); + } + + return $this->columns[$this->currentColumn]; + } + + /** + * @return list + */ + private function indexesByName(string $name): array { + return array_values(array_filter( + $this->indexes, + static fn (Index $index): bool => $index->name === $name + )); + } +} diff --git a/src/Database/Table/Tables/LockTable.php b/src/Database/Table/Tables/LockTable.php new file mode 100644 index 0000000..4870097 --- /dev/null +++ b/src/Database/Table/Tables/LockTable.php @@ -0,0 +1,39 @@ +table; + } + + public function definition(): TableDefinition { + return TableDefinition::for($this) + ->column(new Column('name', 'varbinary', 191)) + ->column(new Column('owner', 'varbinary', 64)) + ->dateTime('expires_at', 6) + ->dateTime('created_at', 6) + ->dateTime('updated_at', 6) + ->primary('name') + ->index('expires_at', 'expires_at'); + } +} diff --git a/src/Database/Table/Tables/MigrationTable.php b/src/Database/Table/Tables/MigrationTable.php new file mode 100644 index 0000000..6a5185e --- /dev/null +++ b/src/Database/Table/Tables/MigrationTable.php @@ -0,0 +1,38 @@ +table; + } + + public function definition(): TableDefinition { + return TableDefinition::for($this) + ->bigIncrements('id') + ->column(new Column('migration', 'varbinary', 191)) + ->unsignedInteger('batch') + ->dateTime('ran_at') + ->unique('migration', 'migration') + ->index('batch', 'batch'); + } +} diff --git a/src/Database/composer.json b/src/Database/composer.json new file mode 100644 index 0000000..e2f0de6 --- /dev/null +++ b/src/Database/composer.json @@ -0,0 +1,26 @@ +{ + "name": "stellarwp/foundation-database", + "type": "library", + "description": "Foundation Database package.", + "license": "GPL-2.0-or-later", + "config": { + "vendor-dir": "vendor", + "preferred-install": "dist" + }, + "require": { + "php": ">=8.3", + "stellarwp/foundation-container": "^2.0", + "stellarwp/foundation-lock": "^2.0", + "stellarwp/foundation-wpcli": "^2.0" + }, + "autoload": { + "psr-4": { + "StellarWP\\Foundation\\Database\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev" + } + } +} diff --git a/src/Database/stubs/migration.stub b/src/Database/stubs/migration.stub new file mode 100644 index 0000000..b576a74 --- /dev/null +++ b/src/Database/stubs/migration.stub @@ -0,0 +1,25 @@ +register_tables(); + $this->register_migrations(); + } + + private function register_tables(): void { + // foundation:database-tables + } + + private function register_migrations(): void { + $this->container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ + ] ); + } + +} diff --git a/src/Database/stubs/table-migration.stub b/src/Database/stubs/table-migration.stub new file mode 100644 index 0000000..9cb6e2a --- /dev/null +++ b/src/Database/stubs/table-migration.stub @@ -0,0 +1,30 @@ +createOrUpdate( $this->table ); + } + + public function down( Schema $schema ): void { + $schema->drop( $this->table ); + } + +} diff --git a/src/Database/stubs/table.stub b/src/Database/stubs/table.stub new file mode 100644 index 0000000..c90ebb7 --- /dev/null +++ b/src/Database/stubs/table.stub @@ -0,0 +1,37 @@ +database->tableName( self::TABLE ); + } + + public function definition(): TableDefinition { + return TableDefinition::for( $this ) + ->bigIncrements( 'id' ) + ->string( 'status', 20 )->default( 'draft' ) + ->longText( 'payload' ) + ->dateTime( 'created_at' ) + ->dateTime( 'updated_at' )->nullable() + ->index( 'status', 'status' ); + } + +} diff --git a/src/Docs/.gitattributes b/src/Docs/.gitattributes new file mode 100644 index 0000000..df7ba69 --- /dev/null +++ b/src/Docs/.gitattributes @@ -0,0 +1,3 @@ +# Ignore repository metadata when GitHub creates a source archive. +/.gitattributes export-ignore +/.github export-ignore diff --git a/src/Docs/.github/workflows/close-pull-request.yml b/src/Docs/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..ddbc0e9 --- /dev/null +++ b/src/Docs/.github/workflows/close-pull-request.yml @@ -0,0 +1,16 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + pull-requests: write + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.

Thanks!" diff --git a/src/Docs/.github/workflows/deploy-docs.yml b/src/Docs/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..899c675 --- /dev/null +++ b/src/Docs/.github/workflows/deploy-docs.yml @@ -0,0 +1,53 @@ +name: Deploy Documentation + +on: + push: + tags: + - '*' + +concurrency: + group: docs-production + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + + permissions: + contents: read + deployments: write + + steps: + - name: Checkout tagged documentation + uses: actions/checkout@v7 + + - name: Verify published Foundation release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [[ "$(gh api "repos/stellarwp/foundation/releases/tags/${GITHUB_REF_NAME}" --jq '.draft == false and .prerelease == false')" != "true" ]]; then + echo "${GITHUB_REF_NAME} is not a published stable Foundation release." + exit 1 + fi + + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version-file: '.nvmrc' + cache: npm + cache-dependency-path: 'package-lock.json' + + - name: Install dependencies + run: npm ci + + - name: Build documentation + run: npm run build + + - name: Deploy production + uses: cloudflare/wrangler-action@v4 + with: + apiToken: ${{ secrets.CLOUDFLARE_DEPLOY_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_DEPLOY_ACCOUNT_ID }} + gitHubToken: ${{ secrets.GITHUB_TOKEN }} + packageManager: npm + command: pages deploy dist --project-name=foundation-docs --branch=production diff --git a/src/Docs/.gitignore b/src/Docs/.gitignore new file mode 100644 index 0000000..8acbc68 --- /dev/null +++ b/src/Docs/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +.astro/ +.wrangler/ +.dev.vars +.dev.vars.* +.env +.env.* +!.env.example +npm-debug.log* +.DS_Store +.idea/ +.vscode/ diff --git a/src/Docs/.nvmrc b/src/Docs/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/src/Docs/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/src/Docs/README.md b/src/Docs/README.md new file mode 100644 index 0000000..00c2c88 --- /dev/null +++ b/src/Docs/README.md @@ -0,0 +1,46 @@ +# Foundation Documentation + +> [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +The source for the Foundation documentation site. + +## Local Development + +Use Node.js 24 and install the locked npm dependencies: + +```shell +nvm install +npm ci +npm run dev +``` + +Astro prints the local documentation URL after the development server starts. + +Create a production build with: + +```shell +npm run build +``` + +Preview the production build with Cloudflare Pages locally: + +```shell +npm run preview:cloudflare +``` + +Wrangler serves the site at `http://localhost:8788`. Press `t` in that terminal +to create a temporary public Cloudflare Tunnel URL for sharing the preview. +The tunnel is publicly accessible and must not be used for production. + +## Deployment + +Documentation previews deploy from same-repository pull requests in +`stellarwp/foundation`. Production deploys only when a stable release tag reaches +the read-only `stellarwp/foundation-docs` split repository. + +Both workflows use a Direct Upload Cloudflare Pages project named +`foundation-docs` whose production branch is `production`. The Cloudflare API +token requires `Account > Cloudflare Pages > Edit`. The +`CLOUDFLARE_DEPLOY_ACCOUNT_ID` and `CLOUDFLARE_DEPLOY_TOKEN` organization Actions secrets +must be available to both repositories. diff --git a/src/Docs/astro.config.mjs b/src/Docs/astro.config.mjs new file mode 100644 index 0000000..a434e17 --- /dev/null +++ b/src/Docs/astro.config.mjs @@ -0,0 +1,67 @@ +import starlight from '@astrojs/starlight'; +import { defineConfig } from 'astro/config'; +import starlightThemeNova from 'starlight-theme-nova'; + +export default defineConfig({ + site: 'https://foundation.nexcess.dev', + integrations: [ + starlight({ + title: 'Foundation', + description: 'Shared PHP infrastructure for Nexcess libraries and WordPress plugins.', + favicon: '/favicon.svg', + customCss: ['./src/styles/custom.css'], + editLink: { + baseUrl: 'https://github.com/stellarwp/foundation/edit/main/src/Docs/', + }, + lastUpdated: true, + plugins: [starlightThemeNova()], + sidebar: [ + { + label: 'Start Here', + items: [ + { slug: 'start/what-is-foundation' }, + { slug: 'start/install-foundation' }, + { slug: 'start/configure-the-container' }, + { slug: 'start/bootstrap-wordpress-plugin' }, + { slug: 'start/register-service-providers' }, + { slug: 'start/scope-foundation' }, + ], + }, + { + label: 'Components', + items: [ + { slug: 'components/container' }, + { + label: 'Database', + collapsed: false, + items: [ + { slug: 'components/database', label: 'Overview' }, + { slug: 'components/database/migrations' }, + { slug: 'components/database/query-builder' }, + { slug: 'components/database/lock' }, + ], + }, + { slug: 'components/lock' }, + { slug: 'components/log' }, + { slug: 'components/identifier' }, + { slug: 'components/pipeline' }, + { slug: 'components/shutdown' }, + { slug: 'components/view' }, + { slug: 'components/wp-cli' }, + ], + }, + { + label: 'Developer Tooling', + items: [{ slug: 'tooling/foundation-cli' }], + }, + ], + social: [ + { + icon: 'github', + label: 'Foundation on GitHub', + href: 'https://github.com/stellarwp/foundation', + }, + ], + }), + ], +}); diff --git a/src/Docs/package-lock.json b/src/Docs/package-lock.json new file mode 100644 index 0000000..f8df4df --- /dev/null +++ b/src/Docs/package-lock.json @@ -0,0 +1,8995 @@ +{ + "name": "@stellarwp/foundation-docs", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@stellarwp/foundation-docs", + "dependencies": { + "@astrojs/starlight": "^0.41.7", + "astro": "^7.2.4", + "starlight-theme-nova": "^0.12.2" + }, + "devDependencies": { + "wrangler": "^4.125.0" + }, + "engines": { + "node": ">=24 <25" + } + }, + "node_modules/@aria-ui/core": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@aria-ui/core/-/core-0.2.1.tgz", + "integrity": "sha512-EEh2mIHL4HnfjIgZocxhCCqPRU6EE5eLFQcGw4FOfomil11jIBKtmqCQkz8qJ9Ni3IBoh/5DvUFaY206X0oY6A==", + "license": "MIT", + "dependencies": { + "@ocavue/utils": "^1.6.0", + "@zag-js/dom-query": "^1.40.0", + "alien-signals": "^3.2.1", + "server-dom-shim": "^1.1.0" + } + }, + "node_modules/@aria-ui/elements": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/@aria-ui/elements/-/elements-0.1.13.tgz", + "integrity": "sha512-+uZaWhNobVCIncKn6wBsnVstWet9IM/W7zmDVk71Ktui4ZNRGSj9tudwnsQdnIPtIqDLHiUIr03NPCEnf99lBg==", + "license": "MIT", + "dependencies": { + "@aria-ui/core": "0.2.1", + "@aria-ui/utils": "0.1.7", + "@floating-ui/dom": "^1.8.0", + "@zag-js/dismissable": "^1.43.0", + "@zag-js/dom-query": "^1.43.0" + } + }, + "node_modules/@aria-ui/utils": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@aria-ui/utils/-/utils-0.1.7.tgz", + "integrity": "sha512-ZKc/JOugSEYqsPUHYomxSbLyK9TcypsnhGL/yK2X14q6qyXVax5vHjLqgZmX5pwm28vYP4FOsUiUUtuSIK/eKw==", + "license": "MIT", + "dependencies": { + "@aria-ui/core": "0.2.1", + "@zag-js/dom-query": "^1.42.0" + } + }, + "node_modules/@astrojs/compiler-binding": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding/-/compiler-binding-0.3.2.tgz", + "integrity": "sha512-8w/9CWmYrAJJ8N0SY3O43ws2BgxoW6u3QsD8u2mE140lMYAlwh+tlNoUeSBq22wVheFuiBbR212l6ixZ2IIgCQ==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@astrojs/compiler-binding-darwin-arm64": "0.3.2", + "@astrojs/compiler-binding-darwin-x64": "0.3.2", + "@astrojs/compiler-binding-linux-arm64-gnu": "0.3.2", + "@astrojs/compiler-binding-linux-arm64-musl": "0.3.2", + "@astrojs/compiler-binding-linux-x64-gnu": "0.3.2", + "@astrojs/compiler-binding-linux-x64-musl": "0.3.2", + "@astrojs/compiler-binding-wasm32-wasi": "0.3.2", + "@astrojs/compiler-binding-win32-arm64-msvc": "0.3.2", + "@astrojs/compiler-binding-win32-x64-msvc": "0.3.2" + } + }, + "node_modules/@astrojs/compiler-binding-darwin-arm64": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-arm64/-/compiler-binding-darwin-arm64-0.3.2.tgz", + "integrity": "sha512-MM8tn8CSimcfytaOla4b6acN8mKWiL/rlAA1fpT3/Wl7dNGSE4y8FjTN/zJVNnb63CsLWG5zZwCt01TXtDKh9g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-darwin-x64": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-darwin-x64/-/compiler-binding-darwin-x64-0.3.2.tgz", + "integrity": "sha512-2lXOlzf8xb7jLomRsf/aswh61/NnGusynB2OwFkK6k4pmOtpfXMYnG0PLfXrEvxXYj69NdCnmUYXtHDd+JOOag==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-arm64-gnu": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-gnu/-/compiler-binding-linux-arm64-gnu-0.3.2.tgz", + "integrity": "sha512-BmU3kWj7qnLrd4vzm49zFEPJ5oFnn1tCT4Vt9hZbqdU5Cmb8GZl7fn6VFsnNfe7B18a2gIFtVzbLINtYl5kBjQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-arm64-musl": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-arm64-musl/-/compiler-binding-linux-arm64-musl-0.3.2.tgz", + "integrity": "sha512-f0heT9ZZEseSu5bHCeb80eL2DH07ArE6U9xi1WT/PEusNjzPmEr3GJsjG1tRLo5VYUUYX7h3ScaqGmGrMOVGmw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-x64-gnu": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-gnu/-/compiler-binding-linux-x64-gnu-0.3.2.tgz", + "integrity": "sha512-M8fOUt0itRpqiGyoEA/ij184s8O+hqbCz3+YozRusOOM3osgGljpDThhbKAJjqh82wOo6FioQ4w8PBvU1XMD5Q==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-linux-x64-musl": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-linux-x64-musl/-/compiler-binding-linux-x64-musl-0.3.2.tgz", + "integrity": "sha512-/Kebk8sO6HnLeSd691JkaAPfN7CqR9/KEXmWvyNPkaKNGmj8rTZ/lf2uXtnPu92Lan84UrKdIKVPy1fSo2encQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-wasm32-wasi": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-wasm32-wasi/-/compiler-binding-wasm32-wasi-0.3.2.tgz", + "integrity": "sha512-pUA6xbcOSB7DhfzIArB8BCAkFfAIqriiR7zl5zOStd6oU2G0kIKj+GUdGnyXyhfiv881Hffyk5tC0mR18sDjDw==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.2.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@astrojs/compiler-binding-win32-arm64-msvc": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-arm64-msvc/-/compiler-binding-win32-arm64-msvc-0.3.2.tgz", + "integrity": "sha512-ESruf+6Qkl1trHUFxI6GSf6t52j8yN2kCNSzMWdzt7V/T09tFHrYzrVaJQohb2C9bJUH76pNvX6Zb51+xCQc9Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-binding-win32-x64-msvc": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-binding-win32-x64-msvc/-/compiler-binding-win32-x64-msvc-0.3.2.tgz", + "integrity": "sha512-wzzVrEbOwbsLWOdEbocskjMRx2aZPxJ7ZbmL+jnpBamFwmigm+2M/wzuM6JWncocgYwLic1csSpalBh96kQKXA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@astrojs/compiler-rs": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler-rs/-/compiler-rs-0.3.2.tgz", + "integrity": "sha512-xlx/T7JovIKduu4ucbTQUxQ5+Q8wxkHxhLjnZk3VlJbhbQ9RLvvuDk1p2YYFYFQ5y14dVm3FGGO4isQXa4F+Tg==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler-binding": "0.3.2" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@astrojs/internal-helpers": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@astrojs/internal-helpers/-/internal-helpers-0.10.4.tgz", + "integrity": "sha512-nozZSy/mKYLqe4YrqbKtdOszedAfXYCtw3wZ0d+CAjz4GqQ4L9rl1ltIL5BlgwmYVinJg/RZ0MgGuWOdlyRZlA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "js-yaml": "^4.3.0", + "picomatch": "^4.0.4", + "retext-smartypants": "^6.2.0", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "unified": "^11.0.5" + } + }, + "node_modules/@astrojs/markdown-remark": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.2.4.tgz", + "integrity": "sha512-MvspGMynWKAjTe4/lTUdmBPHIFKNVLTCF6UlyWGogTGzNrTvjD+D4n48k7h8swxsEPKHK2TwxkZO7uoaCv1Pow==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.10.4", + "@astrojs/prism": "4.0.2", + "github-slugger": "^2.0.0", + "hast-util-from-html": "^2.0.3", + "hast-util-to-text": "^4.0.2", + "mdast-util-definitions": "^6.0.0", + "rehype-raw": "^7.0.0", + "rehype-stringify": "^10.0.1", + "remark-gfm": "^4.0.1", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.1.2", + "remark-smartypants": "^3.0.2", + "unified": "^11.0.5", + "unist-util-remove-position": "^5.0.0", + "unist-util-visit": "^5.1.0", + "unist-util-visit-parents": "^6.0.2", + "vfile": "^6.0.3" + } + }, + "node_modules/@astrojs/markdown-satteri": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@astrojs/markdown-satteri/-/markdown-satteri-0.3.7.tgz", + "integrity": "sha512-NHcHbrKW/opbZnTZQ5nH293BdcK2VV0tjuzI88CLnvy2njiEJVwUQ5KFnYd4NoWgHJCY/I1CyjGWCR4Vv3khXQ==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.10.4", + "@astrojs/prism": "4.0.2", + "github-slugger": "^2.0.0", + "satteri": "^0.10.3" + } + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-darwin-arm64": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.10.5.tgz", + "integrity": "sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-darwin-x64": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-x64/-/satteri-darwin-x64-0.10.5.tgz", + "integrity": "sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-linux-arm64-gnu": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-gnu/-/satteri-linux-arm64-gnu-0.10.5.tgz", + "integrity": "sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-linux-arm64-musl": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-musl/-/satteri-linux-arm64-musl-0.10.5.tgz", + "integrity": "sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-linux-x64-gnu": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-gnu/-/satteri-linux-x64-gnu-0.10.5.tgz", + "integrity": "sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-linux-x64-musl": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-musl/-/satteri-linux-x64-musl-0.10.5.tgz", + "integrity": "sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-wasm32-wasi": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-wasm32-wasi/-/satteri-wasm32-wasi-0.10.5.tgz", + "integrity": "sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.2.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-win32-arm64-msvc": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-arm64-msvc/-/satteri-win32-arm64-msvc-0.10.5.tgz", + "integrity": "sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@bruits/satteri-win32-x64-msvc": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-x64-msvc/-/satteri-win32-x64-msvc-0.10.5.tgz", + "integrity": "sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@astrojs/markdown-satteri/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@astrojs/markdown-satteri/node_modules/satteri": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", + "integrity": "sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.5", + "@types/hast": "^3.0.5", + "@types/mdast": "^4.0.4", + "@types/unist": "^3.0.3" + }, + "optionalDependencies": { + "@bruits/satteri-darwin-arm64": "0.10.5", + "@bruits/satteri-darwin-x64": "0.10.5", + "@bruits/satteri-linux-arm64-gnu": "0.10.5", + "@bruits/satteri-linux-arm64-musl": "0.10.5", + "@bruits/satteri-linux-x64-gnu": "0.10.5", + "@bruits/satteri-linux-x64-musl": "0.10.5", + "@bruits/satteri-wasm32-wasi": "0.10.5", + "@bruits/satteri-win32-arm64-msvc": "0.10.5", + "@bruits/satteri-win32-x64-msvc": "0.10.5" + } + }, + "node_modules/@astrojs/mdx": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/@astrojs/mdx/-/mdx-7.0.7.tgz", + "integrity": "sha512-hv+NJh2s+/KDrjXaYNOaS344e3M2ekMXHBR7x9EwBwE3VvE1y/9Ifh1rhR1H2zK2sMgXoUnakI9e9ZeCKPX9/Q==", + "license": "MIT", + "dependencies": { + "@astrojs/internal-helpers": "0.10.4", + "@astrojs/markdown-remark": "7.2.4", + "@mdx-js/mdx": "^3.1.1", + "acorn": "^8.16.0", + "es-module-lexer": "^2.0.0", + "estree-util-visit": "^2.0.0", + "hast-util-to-html": "^9.0.5", + "piccolore": "^0.1.3", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1", + "remark-smartypants": "^3.0.2", + "source-map": "^0.7.6", + "unist-util-visit": "^5.1.0", + "vfile": "^6.0.3" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "@astrojs/markdown-satteri": "^0.3.1", + "astro": "^7.0.0" + }, + "peerDependenciesMeta": { + "@astrojs/markdown-satteri": { + "optional": true + } + } + }, + "node_modules/@astrojs/prism": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.2.tgz", + "integrity": "sha512-KTivpmnz6lDsC6o9H4+DNm2SrE/GHzw8cNAvEJwAvUT+eoaEnn/4NtbDNfRRaxaJHdp15gf+tfHAWiXR4wB3BA==", + "license": "MIT", + "dependencies": { + "prismjs": "^1.30.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@astrojs/sitemap": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.3.tgz", + "integrity": "sha512-f8euLVsyeAmAkSm/1M2Kb8sL8byQmfgbvBNaHFItCheTj/IpiJYSEWVcqDHZ/yEHxiS7+w87mQkzwZaPHmk5GA==", + "license": "MIT", + "dependencies": { + "sitemap": "^9.0.0", + "stream-replace-string": "^2.0.0", + "zod": "^4.3.6" + } + }, + "node_modules/@astrojs/starlight": { + "version": "0.41.7", + "resolved": "https://registry.npmjs.org/@astrojs/starlight/-/starlight-0.41.7.tgz", + "integrity": "sha512-579VJuZgo20UpNQPm9EIez5W3DFSrD16uiV2YX6rUlpLtjgKSdnc69TxVTZXn4AtI2B731TI2qhW1O3K+vwtrQ==", + "license": "MIT", + "dependencies": { + "@astrojs/markdown-satteri": "^0.3.5", + "@astrojs/mdx": "^7.0.5", + "@astrojs/sitemap": "^3.7.3", + "@pagefind/default-ui": "^1.3.0", + "@types/hast": "^3.0.4", + "@types/js-yaml": "^4.0.9", + "@types/mdast": "^4.0.4", + "astro-expressive-code": "^0.44.0", + "bcp-47": "^2.1.0", + "hast-util-from-html": "^2.0.3", + "hast-util-select": "^6.0.4", + "hast-util-to-string": "^3.0.1", + "hastscript": "^9.0.1", + "i18next": "^26.0.7", + "js-yaml": "^4.1.1", + "klona": "^2.0.6", + "magic-string": "^0.30.21", + "mdast-util-directive": "^3.1.0", + "mdast-util-to-markdown": "^2.1.2", + "mdast-util-to-string": "^4.0.0", + "pagefind": "^1.5.2", + "rehype": "^13.0.2", + "rehype-format": "^5.0.1", + "remark-directive": "^4.0.0", + "satteri": "^0.9.1", + "ultrahtml": "^1.6.0", + "unified": "^11.0.5", + "unist-util-visit": "^5.1.0", + "vfile": "^6.0.3" + }, + "peerDependencies": { + "@astrojs/markdown-remark": "^7.2.0", + "astro": "^7.0.2" + }, + "peerDependenciesMeta": { + "@astrojs/markdown-remark": { + "optional": true + } + } + }, + "node_modules/@astrojs/telemetry": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.3.tgz", + "integrity": "sha512-C1TLn5sPJr0x4vk56piHWKbnqlEB8BKyte5Y45V02U+D7BGO5eMqZDH5aPjnkXQWJggvmsTXxH03QMZ9NgWLzQ==", + "license": "MIT", + "dependencies": { + "ci-info": "^4.4.0", + "dset": "^3.1.4", + "is-docker": "^4.0.0", + "package-manager-detector": "^1.6.0" + }, + "engines": { + "node": "18.20.8 || ^20.3.0 || >=22.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bruits/satteri-darwin-arm64": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-arm64/-/satteri-darwin-arm64-0.9.5.tgz", + "integrity": "sha512-iw4nZgx9v30lWo/MTngQqi1pI78KI0DnkSm+lVJGYdmPLgAyDNJigVhpG42/Iq55A6c1Ll8q66ljyyRiQUxwow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@bruits/satteri-darwin-x64": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-darwin-x64/-/satteri-darwin-x64-0.9.5.tgz", + "integrity": "sha512-6T26Z5Kf3cFW2PSlk9p7zT7yVxvuBSiJvYyz9u8KjYwMTqZyIDOj2wDyNpxKV4+6yUVG7rddq2QwvG/8LJA2+Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@bruits/satteri-linux-arm64-gnu": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-gnu/-/satteri-linux-arm64-gnu-0.9.5.tgz", + "integrity": "sha512-u51id17uJwNEMK9nBlICsq6U31c+XVqQueVBkwRIzZG+gMpS8TOJctt5h5Wz33Z8xnMdTd+adtACVz0yHgGuOA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-arm64-musl": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-arm64-musl/-/satteri-linux-arm64-musl-0.9.5.tgz", + "integrity": "sha512-v39HxiwGC5Rqm01HksP6+5Y+xKLPlsuVFgIgpEAo+SiQ22c+mJVhS3u7Z6ePAKdhL5NJoK1xq70kLz3L13AhpQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-x64-gnu": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-gnu/-/satteri-linux-x64-gnu-0.9.5.tgz", + "integrity": "sha512-F3uO8uFp3pAP5ZGXttwvh57GS7s0lL953tnNdyI2gRyP4kOOkp6pyGojNJzCjkDvWI2Cvb9iNrKok3aqQPauAw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-linux-x64-musl": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-linux-x64-musl/-/satteri-linux-x64-musl-0.9.5.tgz", + "integrity": "sha512-bicEqglLlz++mWyADaZoP0JY20s4vDfLjaPYgQqC+NI4zZLTOOg1T4GB8aqtc822Pqji8SQBmSrTb7CrP8i08Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@bruits/satteri-wasm32-wasi": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-wasm32-wasi/-/satteri-wasm32-wasi-0.9.5.tgz", + "integrity": "sha512-zauAuMwfPnKPUkd4AFixRFpXdgKwP2mKgxrIIo2gJzW0/ZneF9dbHnLkojSpaBnCCp7VUL1hIi5WWZvB1CqmAQ==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@bruits/satteri-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@bruits/satteri-win32-arm64-msvc": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-arm64-msvc/-/satteri-win32-arm64-msvc-0.9.5.tgz", + "integrity": "sha512-SrfE7NEsgZjBvU3c+RR6oQRu0ToXY5uVJEbieXEF0YTctIV2zAVlbaMjWLts074QCgh3a+XHWkR/lWh2VH2LUg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@bruits/satteri-win32-x64-msvc": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/@bruits/satteri-win32-x64-msvc/-/satteri-win32-x64-msvc-0.9.5.tgz", + "integrity": "sha512-5Kw9ZAtTGS8WHizyn+CJhjjfIQrw+7jcZodpmpXJjefnO15M8UexIi6JR2E5thyvsmHyhL6ZDDMUNR4bKJPd4g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@capsizecss/unpack": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.1.tgz", + "integrity": "sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@clack/core": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.3.tgz", + "integrity": "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==", + "license": "MIT", + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.7.0.tgz", + "integrity": "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==", + "license": "MIT", + "dependencies": { + "@clack/core": "1.4.3", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260820.1.tgz", + "integrity": "sha512-5F2/t7SVnugG3rscSe9da1LoHst+GiuVGPaE9BP6j5AonlFpiYi0eoJrl3wof9zDBIIgJZ87NjRj9TKjbbYgHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260820.1.tgz", + "integrity": "sha512-jFnG7715+r9FXRZPpuWWe5Ayd9v/IJKDODARg56ffFJWWtte6bFNi5VY3GazBM04hEmxny6OjXQBh3j2E/wNZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260820.1.tgz", + "integrity": "sha512-TbTYaCBht0OaOWmnVpg43hXVYtIti/Kg6lvO819H2DwbxPpK1BH0z2C+Y7ySrVJLlxZQeic5eN7/noqbP80hJg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260820.1.tgz", + "integrity": "sha512-FQmri1UF7hBnnpeyC5SZJCAnsSRs3+Ykn9wlO5zxmE2qIgKfbJZ+toJ6qrQyugWlxE65juT33HU6aYLtutLJHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260820.1.tgz", + "integrity": "sha512-BPvCuMxIQfA47wtYsGbBG6Bcar57Qs7yHqU2jWkT1+sRnL/741/ZbQGP9kVRMQ5fwBMuqNFmQRzAu8gSm7ri/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@expressive-code/core": { + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/@expressive-code/core/-/core-0.44.1.tgz", + "integrity": "sha512-3dDo9N8D7hYrLNNMMWFovg3+aDUtnQm7c7z0GZc1c0LEFVBc0Q6lKG+tVT28gDadOvsgOANfCn35fgpe97Pmgg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.0.4", + "hast-util-select": "^6.0.2", + "hast-util-to-html": "^9.0.1", + "hast-util-to-text": "^4.0.1", + "hastscript": "^9.0.0", + "postcss": "^8.4.38", + "postcss-nested": "^6.0.1", + "unist-util-visit": "^5.0.0", + "unist-util-visit-parents": "^6.0.1" + } + }, + "node_modules/@expressive-code/plugin-frames": { + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-frames/-/plugin-frames-0.44.1.tgz", + "integrity": "sha512-HC/bdRao9225ApcgO/e3jn8ZOhldKO7ob1O/Tcipvtv7Vb5nMphZhMtD9uuywpvxkPYBHJi3504WhrKg05Dwqg==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.44.1" + } + }, + "node_modules/@expressive-code/plugin-shiki": { + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-shiki/-/plugin-shiki-0.44.1.tgz", + "integrity": "sha512-YApiZt3buUzBwL5tqj8G+sYC5NjMjRCHgQwr9bmGl69rtcHy6fE9dooWUeKYB978fJT2BuxT5FeHcF47rA3SEg==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.44.1", + "shiki": "^4.0.2" + } + }, + "node_modules/@expressive-code/plugin-text-markers": { + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/@expressive-code/plugin-text-markers/-/plugin-text-markers-0.44.1.tgz", + "integrity": "sha512-B3BsJoJ8CFMlcIX9f+X9tcI3C4zPDO601+YuLi9GheSTNro7ZfqSjLptMQKBHOWZvxnAtY5zvIX7iO/qtBhNBg==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.44.1" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@lit-labs/ssr-dom-shim": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz", + "integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@mdx-js/mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@mdx-js/mdx/-/mdx-3.1.1.tgz", + "integrity": "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdx": "^2.0.0", + "acorn": "^8.0.0", + "collapse-white-space": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-util-scope": "^1.0.0", + "estree-walker": "^3.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "markdown-extensions": "^2.0.0", + "recma-build-jsx": "^1.0.0", + "recma-jsx": "^1.0.0", + "recma-stringify": "^1.0.0", + "rehype-recma": "^1.0.0", + "remark-mdx": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "source-map": "^0.7.0", + "unified": "^11.0.0", + "unist-util-position-from-estree": "^2.0.0", + "unist-util-stringify-position": "^4.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@ocavue/utils": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@ocavue/utils/-/utils-1.7.0.tgz", + "integrity": "sha512-yEk9ATNBjTZTtuVFMB/MAIF6zJBvJ2+lVNQvK2+O+ggEBGTgx2tp27d4FPgmD5bRsNHHP3D0SleQia/bvIeV8w==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ocavue" + } + }, + "node_modules/@oslojs/encoding": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==", + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.146.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", + "integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pagefind/darwin-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.5.2.tgz", + "integrity": "sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/darwin-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/darwin-x64/-/darwin-x64-1.5.2.tgz", + "integrity": "sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@pagefind/default-ui": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/default-ui/-/default-ui-1.5.2.tgz", + "integrity": "sha512-pm1LMnQg8N2B3n2TnjKlhaFihpz6zTiA4HiGQ6/slKO/+8K9CAU5kcjdSSPgpuk1PMuuN4hxLipUIifnrkl3Sg==", + "license": "MIT" + }, + "node_modules/@pagefind/freebsd-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/freebsd-x64/-/freebsd-x64-1.5.2.tgz", + "integrity": "sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@pagefind/linux-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/linux-arm64/-/linux-arm64-1.5.2.tgz", + "integrity": "sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/linux-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.5.2.tgz", + "integrity": "sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@pagefind/windows-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/windows-arm64/-/windows-arm64-1.5.2.tgz", + "integrity": "sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@pagefind/windows-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@pagefind/windows-x64/-/windows-x64-1.5.2.tgz", + "integrity": "sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@poppinss/colors": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^4.1.5" + } + }, + "node_modules/@poppinss/dumper": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@sindresorhus/is": "^7.0.2", + "supports-color": "^10.0.0" + } + }, + "node_modules/@poppinss/dumper/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/@poppinss/exception": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.5.tgz", + "integrity": "sha512-DLe/i+l8ynIBY7XEQ191TeZvCoowIGa18R+dIV30GW7DiOtp74i/xX8hs8GUjW5ARV7VZuie3d6AumSmCwbeRA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.5.tgz", + "integrity": "sha512-zXcwKlQApYAOELHd8PwKDFkagYF9Wy4e0RJ+0qnzl9Pjnpj75TEG8ufv40p2J7kCEfwZAsNiuzRIyNNMWT38ig==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.5.tgz", + "integrity": "sha512-dK4QakI42nzWgJT5sm4y4y/O//D4OxM75/cH28RLV+nzIN9AY+YsbuUVrUTjlLjXR6vpyxFbSsbmNuJ6BP9sww==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.5.tgz", + "integrity": "sha512-fqSALaUu1Wjd1nK2uW2kJDWdLCc8lx1IcY+MTY26Aurfdx19anlzhqXOgCFbBFQnlFDTn4TC1/7Nz4Bl2mLP3A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.5.tgz", + "integrity": "sha512-/vCnNxlkxs9tKxNDcyWUePpJ/PgTzxIaVhoM5SmG8UV+GR/IcPam4VYxi7GIMo7PSDuNqlJqvprqii9NqqVCMw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.5.tgz", + "integrity": "sha512-abk0NLA519LxRCszmbE0jYKuQ9YPocOXTiOXOo6Yr+YAT95VH+PtqYAjOJvGKt3viEd/x4qzabAlwd5bHOOARg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.5.tgz", + "integrity": "sha512-Y7eALiJ8lr0M2HH103Js+g7V34wf6snlpZLAsHI90uLhr3PVlNsbFVAXJC9d/V6BnPyKtpSwI+NcB/RLxsQxuA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.5.tgz", + "integrity": "sha512-xMvZgnbZg4YVnR/AX2b3oOPDTFYJvUVaJg5FedA/LuvexAtXibZQej4cnTkw3rjsJ/ggUROB64TdtETiim+FYA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.5.tgz", + "integrity": "sha512-GRjeqTUDHTo5GwntsLaAMcBahG3nlpjftXWZLN73HiYQlhwEowvarFgQnRnQZtIp4keXX7quXFbG38uPZBa2EA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.5.tgz", + "integrity": "sha512-vLNTR45F2Uwc8AufkNXPmB4VliaXs+FvcheEogIzOXzO4l+LzieXF5A/TWxLy5HtqpsRCHUfd0lPVrrdgXdLHQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.5.tgz", + "integrity": "sha512-Mgj59/HTuYeK9Gz2MA+mBWKnHsAgkBSec15ZMb1st3oIfFbX7gCjOae7GydHhzcyQi9Z/7M1QuN9bR3oFqF0jQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.5.tgz", + "integrity": "sha512-mY8AP0/ichsbhAxGnLa3d3+MwV0EfgrPND2bplI3Ym8T6R2pJ0N87bvrKVwNXmdy3jnr6eQBecdqx/HMknBmpA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.5.tgz", + "integrity": "sha512-8SLssA2oweAxyRgDp789ACfRb/3P+zNRJpzZxSizxF9m8NUDQ4+3xjo8ttjhVGGw6Qxb70oZiEtIjaKikCO7Yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.5.tgz", + "integrity": "sha512-vGbruD5zquhoc8D9SViXgN2FBJtNdTyQ4DtG+SWiEGlJiAzoKcZ2xp+xuXCffhubVdt0NJlTZqkeRuERy7g8Cw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.5.tgz", + "integrity": "sha512-e/SXpgISz+IoqVcSSI0rx/d/he8zqLex+/rCWpnHpmVfmPIUjag9H6P7zotf0gJHwPUhQxZ/mF8tr6acebT9yw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@shikijs/core": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz", + "integrity": "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==", + "license": "MIT", + "dependencies": { + "@shikijs/primitive": "4.4.3", + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5", + "hast-util-to-html": "^9.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz", + "integrity": "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "oniguruma-to-es": "^4.3.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz", + "integrity": "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/langs": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.3.tgz", + "integrity": "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/primitive": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.3.tgz", + "integrity": "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/themes": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.3.tgz", + "integrity": "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==", + "license": "MIT", + "dependencies": { + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/transformers": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-4.4.3.tgz", + "integrity": "sha512-oJSARV6NaWd+rnNJbtnpAdj3Zg0ZVyzsnMgb3vi3HA+35y8lBWUCpOnWsmyiXZIikY+x1BDqrQUgmxfzWh7Jvw==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.4.3", + "@shikijs/types": "4.4.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/twoslash": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/twoslash/-/twoslash-4.4.3.tgz", + "integrity": "sha512-m7HNzunEIHRk1jCya3ngGsO3+8pYxrPIIxtdJewg/W8ceW/+m/mSsm4jM3L9DvYYNa8Rvbu7Dabt3BOpCclz8Q==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.4.3", + "@shikijs/types": "4.4.3", + "twoslash": "^0.3.9" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "typescript": ">=5.5.0" + } + }, + "node_modules/@shikijs/types": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz", + "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==", + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@speed-highlight/core": { + "version": "1.2.24", + "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.24.tgz", + "integrity": "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdx": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.14.tgz", + "integrity": "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/nlcst": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/nlcst/-/nlcst-2.0.3.tgz", + "integrity": "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/sax": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", + "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@typescript/vfs": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", + "integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, + "node_modules/@zag-js/dismissable": { + "version": "1.43.3", + "resolved": "https://registry.npmjs.org/@zag-js/dismissable/-/dismissable-1.43.3.tgz", + "integrity": "sha512-3znmDAC6qv7I9h/ZInVE+sFxE2wcBeQhT0bEiUjXAlgFGgaVmgA6A0ElGI7CDrot8qhgC88BZQisMWfYRRWgeg==", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.43.3", + "@zag-js/interact-outside": "1.43.3", + "@zag-js/utils": "1.43.3" + } + }, + "node_modules/@zag-js/dom-query": { + "version": "1.43.3", + "resolved": "https://registry.npmjs.org/@zag-js/dom-query/-/dom-query-1.43.3.tgz", + "integrity": "sha512-kpTTYMemMmHxhp4gyOllK7/Lf86AySTO+AlGds9iQM21eUwi1jvc9q3VTxP+qD/KwHePGftfPwkwxifC3iSGbw==", + "license": "MIT", + "dependencies": { + "@zag-js/types": "1.43.3" + } + }, + "node_modules/@zag-js/interact-outside": { + "version": "1.43.3", + "resolved": "https://registry.npmjs.org/@zag-js/interact-outside/-/interact-outside-1.43.3.tgz", + "integrity": "sha512-qgyAyWELSzFrHUtB6D7IzemI48NjX7E5oIDCXsz/DBPh+ybsK+pKMGsZkduTCK+uEqJcA/w+DAWaDUOxhjqvWA==", + "license": "MIT", + "dependencies": { + "@zag-js/dom-query": "1.43.3", + "@zag-js/utils": "1.43.3" + } + }, + "node_modules/@zag-js/types": { + "version": "1.43.3", + "resolved": "https://registry.npmjs.org/@zag-js/types/-/types-1.43.3.tgz", + "integrity": "sha512-QokzUgkJ7a/TRE8SAXqlm1XfiNDbyM8y+mx0PpmzHa8bvmWXLEe/Zx2TRz8aYoGim75NluYzDy3x+WWileF7uA==", + "license": "MIT", + "dependencies": { + "csstype": "3.2.3" + } + }, + "node_modules/@zag-js/utils": { + "version": "1.43.3", + "resolved": "https://registry.npmjs.org/@zag-js/utils/-/utils-1.43.3.tgz", + "integrity": "sha512-9P9fvFFxuiDcLqX0BYgGNkf0/a/id32nHvQpwgv/Xv3b9n5yeyw2U8nKefpWr+1VfFsrpf2XMXiA2CDSWZgt2g==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "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==", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/alien-signals": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.2.1.tgz", + "integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==", + "license": "MIT" + }, + "node_modules/am-i-vibing": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.4.0.tgz", + "integrity": "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg==", + "license": "MIT", + "dependencies": { + "process-ancestry": "^0.1.0" + }, + "bin": { + "am-i-vibing": "dist/cli.mjs" + } + }, + "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==", + "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==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "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/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-iterate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/array-iterate/-/array-iterate-2.0.1.tgz", + "integrity": "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, + "node_modules/astro": { + "version": "7.2.4", + "resolved": "https://registry.npmjs.org/astro/-/astro-7.2.4.tgz", + "integrity": "sha512-+cuLsBns2wwUHI9a10xZMbjrF91m7+QNwqTVeljTx0B8Lf+8h0LgVGjdVIL2FALDKD2I565lczeeS3BFC+KdZg==", + "license": "MIT", + "dependencies": { + "@astrojs/compiler-rs": "^0.3.2", + "@astrojs/internal-helpers": "0.10.4", + "@astrojs/markdown-satteri": "0.3.7", + "@astrojs/telemetry": "3.3.3", + "@capsizecss/unpack": "^4.0.0", + "@clack/prompts": "^1.1.0", + "@oslojs/encoding": "^1.1.0", + "am-i-vibing": "^0.4.0", + "aria-query": "^5.3.2", + "axobject-query": "^4.1.0", + "ci-info": "^4.4.0", + "clsx": "^2.1.1", + "common-ancestor-path": "^2.0.0", + "cookie": "^2.0.1", + "devalue": "^5.8.1", + "diff": "^8.0.3", + "dset": "^3.1.4", + "es-module-lexer": "^2.0.0", + "esbuild": "^0.28.0", + "find-process": "^2.1.1", + "flattie": "^1.1.1", + "fontace": "~0.4.1", + "get-tsconfig": "5.0.0-beta.4", + "github-slugger": "^2.0.0", + "html-escaper": "3.0.3", + "http-cache-semantics": "^4.2.0", + "js-yaml": "^4.3.0", + "jsonc-parser": "^3.3.1", + "magic-string": "^1.0.0", + "magicast": "^0.5.2", + "mrmime": "^2.0.1", + "neotraverse": "^1.0.1", + "obug": "^2.1.1", + "p-limit": "^7.3.0", + "p-queue": "^9.1.0", + "package-manager-detector": "^1.6.0", + "piccolore": "^0.1.3", + "picomatch": "^4.0.4", + "semver": "^7.7.4", + "shiki": "^4.0.2", + "smol-toml": "^1.6.0", + "svgo": "^4.0.1", + "tinyclip": "^0.1.12", + "tinyexec": "^1.0.4", + "tinyglobby": "^0.2.15", + "ultrahtml": "^1.6.0", + "unifont": "~0.7.5", + "unstorage": "^1.17.5", + "vite": "^8.0.13", + "vitefu": "^1.1.2", + "xxhash-wasm": "^1.1.0", + "yargs-parser": "^22.0.0", + "zod": "^4.3.6" + }, + "bin": { + "astro": "bin/astro.mjs" + }, + "engines": { + "node": ">=22.12.0", + "npm": ">=9.6.5", + "pnpm": ">=7.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/astrodotbuild" + }, + "optionalDependencies": { + "sharp": "^0.34.0 || ^0.35.0" + }, + "peerDependencies": { + "@astrojs/markdown-remark": "7.2.4" + }, + "peerDependenciesMeta": { + "@astrojs/markdown-remark": { + "optional": true + } + } + }, + "node_modules/astro-expressive-code": { + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/astro-expressive-code/-/astro-expressive-code-0.44.1.tgz", + "integrity": "sha512-DT1LnCqbHasBKlvzJ3m6LR4VI94wwx3W9EV/YbP1te4rqjOHsvsezHYuqb5MeLWLftXms/1FA9QBbwCo43DnJQ==", + "license": "MIT", + "dependencies": { + "rehype-expressive-code": "^0.44.1", + "url-extras": "^0.1.0" + }, + "peerDependencies": { + "astro": "^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0" + } + }, + "node_modules/astro-theme-toggle": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/astro-theme-toggle/-/astro-theme-toggle-0.8.2.tgz", + "integrity": "sha512-x6DKMc4V5q47s5Wc3tvC11Yhd9VFVA8XQflD23GX+BpdEIv2wLN0592wIllrCtrMgiv6TgdiVSruhx18qg2Qtw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ocavue" + } + }, + "node_modules/astro/node_modules/magic-string": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.2.tgz", + "integrity": "sha512-veT/+7iXrXzT39XnEN4lOxtNl72dMgJ8Lp+5Bd6YcMSWpb0n0MjBM8Uuooi6jgJr8dhUW2swQgBmoZVMni5SVg==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/bcp-47": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bcp-47/-/bcp-47-2.1.1.tgz", + "integrity": "sha512-KLw+H/gd2p4zly1X7Yh/qziuyae5/w/QFnvTng9eZL5fvszL7Whl3MBoWF8yxL7ksUjBfOD+OxkytiqbBpG+Fw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/bcp-47-match": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz", + "integrity": "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/blake3-wasm": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "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/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/collapse-white-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", + "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "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==", + "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==", + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/common-ancestor-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz", + "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } + }, + "node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie-es": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-1.2.3.tgz", + "integrity": "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==", + "license": "MIT" + }, + "node_modules/crossws": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.3.5.tgz", + "integrity": "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-selector-parser": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/css-selector-parser/-/css-selector-parser-3.3.0.tgz", + "integrity": "sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.1.tgz", + "integrity": "sha512-+17vil3EVQRzvtDJSFuTWEb8XJRvXqAiV3qZyQWD398QeXUa6CxsUyMdD1fxzEhUrd4FojitFz7lhIHBTlV4fw==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/direction": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/direction/-/direction-2.0.1.tgz", + "integrity": "sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==", + "license": "MIT", + "bin": { + "direction": "cli.js" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dset": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/dset/-/dset-3.1.4.tgz", + "integrity": "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-stack-parser-es": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "license": "MIT" + }, + "node_modules/esast-util-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/esast-util-from-estree/-/esast-util-from-estree-2.0.0.tgz", + "integrity": "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esast-util-from-js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esast-util-from-js/-/esast-util-from-js-2.0.1.tgz", + "integrity": "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "acorn": "^8.0.0", + "esast-util-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-attach-comments": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-attach-comments/-/estree-util-attach-comments-3.0.0.tgz", + "integrity": "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-build-jsx": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/estree-util-build-jsx/-/estree-util-build-jsx-3.0.1.tgz", + "integrity": "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-walker": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-scope": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/estree-util-scope/-/estree-util-scope-1.0.0.tgz", + "integrity": "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-to-js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-to-js/-/estree-util-to-js-2.0.0.tgz", + "integrity": "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "astring": "^1.8.0", + "source-map": "^0.7.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-visit": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/estree-util-visit/-/estree-util-visit-2.0.0.tgz", + "integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expressive-code": { + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/expressive-code/-/expressive-code-0.44.1.tgz", + "integrity": "sha512-GakidxhapWDzpKLqEaFQ8wGk6gAqEtPQibu8+yPBfnDLgev5Vdsh1pasTxnrXL/mzIknyqeTwhMHTghdaiUrTg==", + "license": "MIT", + "dependencies": { + "@expressive-code/core": "^0.44.1", + "@expressive-code/plugin-frames": "^0.44.1", + "@expressive-code/plugin-shiki": "^0.44.1", + "@expressive-code/plugin-text-markers": "^0.44.1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "license": "MIT", + "dependencies": { + "fast-string-truncated-width": "^3.0.2" + } + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "license": "MIT", + "dependencies": { + "fast-string-width": "^3.0.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-process": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/find-process/-/find-process-2.1.1.tgz", + "integrity": "sha512-SrQDx3QhlmHM90iqn9rdjCQcw/T+WlpOkHFsjoRgB+zTpDfltNA1VSNYeYELwhUTJy12UFxqjWhmhOrJc+o4sA==", + "license": "MIT", + "dependencies": { + "chalk": "~4.1.2", + "commander": "^14.0.3", + "loglevel": "^1.9.2" + }, + "bin": { + "find-process": "dist/cjs/bin/find-process.js" + } + }, + "node_modules/flattie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flattie/-/flattie-1.1.1.tgz", + "integrity": "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/fontace": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/fontace/-/fontace-0.4.1.tgz", + "integrity": "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==", + "license": "MIT", + "dependencies": { + "fontkitten": "^1.0.2" + } + }, + "node_modules/fontkitten": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/fontkitten/-/fontkitten-1.0.3.tgz", + "integrity": "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==", + "license": "MIT", + "dependencies": { + "tiny-inflate": "^1.0.3" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "5.0.0-beta.4", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-5.0.0-beta.4.tgz", + "integrity": "sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==", + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "engines": { + "node": ">=20.20.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, + "node_modules/h3": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz", + "integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==", + "license": "MIT", + "dependencies": { + "cookie-es": "^1.2.3", + "crossws": "^0.3.5", + "defu": "^6.1.6", + "destr": "^2.0.5", + "iron-webcrypto": "^1.2.1", + "node-mock-http": "^1.0.4", + "radix3": "^1.1.2", + "ufo": "^1.6.3", + "uncrypto": "^0.1.3" + } + }, + "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==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hast-util-embedded": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-embedded/-/hast-util-embedded-3.0.0.tgz", + "integrity": "sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-format": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hast-util-format/-/hast-util-format-1.1.0.tgz", + "integrity": "sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-minify-whitespace": "^1.0.0", + "hast-util-phrasing": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "html-whitespace-sensitive-tag-names": "^3.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-has-property": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-has-property/-/hast-util-has-property-3.0.0.tgz", + "integrity": "sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-body-ok-link": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-is-body-ok-link/-/hast-util-is-body-ok-link-3.0.1.tgz", + "integrity": "sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-minify-whitespace": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hast-util-minify-whitespace/-/hast-util-minify-whitespace-1.0.1.tgz", + "integrity": "sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-phrasing": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-phrasing/-/hast-util-phrasing-3.0.1.tgz", + "integrity": "sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-embedded": "^3.0.0", + "hast-util-has-property": "^3.0.0", + "hast-util-is-body-ok-link": "^3.0.0", + "hast-util-is-element": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-select": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/hast-util-select/-/hast-util-select-6.0.4.tgz", + "integrity": "sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "bcp-47-match": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "css-selector-parser": "^3.0.0", + "devlop": "^1.0.0", + "direction": "^2.0.0", + "hast-util-has-property": "^3.0.0", + "hast-util-to-string": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "nth-check": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-estree": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz", + "integrity": "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-attach-comments": "^3.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", + "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-string": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", + "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/html-whitespace-sensitive-tag-names": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-whitespace-sensitive-tag-names/-/html-whitespace-sensitive-tag-names-3.0.1.tgz", + "integrity": "sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/i18next": { + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.0.tgz", + "integrity": "sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/iron-webcrypto": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz", + "integrity": "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/brc-dd" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-docker": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-4.0.0.tgz", + "integrity": "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/markdown-extensions": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-extensions/-/markdown-extensions-2.0.0.tgz", + "integrity": "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-definitions": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", + "integrity": "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-directive": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-directive/-/mdast-util-directive-3.1.0.tgz", + "integrity": "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx/-/mdast-util-mdx-3.0.0.tgz", + "integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "license": "CC0-1.0" + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-directive/-/micromark-extension-directive-4.0.0.tgz", + "integrity": "sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-expression": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-expression/-/micromark-extension-mdx-expression-3.0.1.tgz", + "integrity": "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-jsx": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-jsx/-/micromark-extension-mdx-jsx-3.0.2.tgz", + "integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-md": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdx-md/-/micromark-extension-mdx-md-2.0.0.tgz", + "integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs/-/micromark-extension-mdxjs-3.0.0.tgz", + "integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==", + "license": "MIT", + "dependencies": { + "acorn": "^8.0.0", + "acorn-jsx": "^5.0.0", + "micromark-extension-mdx-expression": "^3.0.0", + "micromark-extension-mdx-jsx": "^3.0.0", + "micromark-extension-mdx-md": "^2.0.0", + "micromark-extension-mdxjs-esm": "^3.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs-esm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-mdxjs-esm/-/micromark-extension-mdxjs-esm-3.0.0.tgz", + "integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-mdx-expression": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-factory-mdx-expression/-/micromark-factory-mdx-expression-2.0.3.tgz", + "integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-events-to-acorn": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-events-to-acorn/-/micromark-util-events-to-acorn-2.0.3.tgz", + "integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/miniflare": { + "version": "5.20260820.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260820.0-alpha.tgz", + "integrity": "sha512-Bv1j2kcKKNwXLWuCx+j0xGt7z318mqQkJmEN6ellM9sCESbPBDTM9ofZMbKqx47jSnoGA3CiaUkAmzGVXUa/wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.29.0", + "workerd": "1.20260820.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.1" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-darwin-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.1" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-linux-arm": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.1" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-linux-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.1" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.1" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.1" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-linux-s390x": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.1" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-linux-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.1" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-win32-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-win32-ia32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/@img/sharp-win32-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/miniflare/node_modules/sharp": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" + } + }, + "node_modules/miniflare/node_modules/undici": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neotraverse": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/neotraverse/-/neotraverse-1.0.1.tgz", + "integrity": "sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/nlcst-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/nlcst-to-string/-/nlcst-to-string-4.0.0.tgz", + "integrity": "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "license": "MIT" + }, + "node_modules/node-mock-http": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/node-mock-http/-/node-mock-http-1.0.5.tgz", + "integrity": "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==", + "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==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/ohash": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", + "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", + "license": "MIT" + }, + "node_modules/oniguruma-parser": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==", + "license": "MIT" + }, + "node_modules/oniguruma-to-es": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz", + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", + "license": "MIT", + "dependencies": { + "oniguruma-parser": "^0.12.2", + "regex": "^6.1.0", + "regex-recursion": "^6.0.2" + } + }, + "node_modules/p-limit": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.1.tgz", + "integrity": "sha512-0trZaiG7Y7kN/Egy9a8j47t9osC0Tch4PaIWd9yGF6bvmlk7muExRvGNYb8sXBwEKMoNKsbNN9P8EefuQekE4Q==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.2.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "9.3.3", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.3.tgz", + "integrity": "sha512-NXAOdnEe5FsZJfT4oK84lE1Y5cFFdWlRuOo5tww8DyNMxyRXwn39fIkUtNLKppcPC+UYU/bXujNCUGDv01y7CA==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "license": "MIT" + }, + "node_modules/pagefind": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/pagefind/-/pagefind-1.5.2.tgz", + "integrity": "sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==", + "license": "MIT", + "bin": { + "pagefind": "lib/runner/bin.cjs" + }, + "optionalDependencies": { + "@pagefind/darwin-arm64": "1.5.2", + "@pagefind/darwin-x64": "1.5.2", + "@pagefind/freebsd-x64": "1.5.2", + "@pagefind/linux-arm64": "1.5.2", + "@pagefind/linux-x64": "1.5.2", + "@pagefind/windows-arm64": "1.5.2", + "@pagefind/windows-x64": "1.5.2" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse-latin": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse-latin/-/parse-latin-7.0.0.tgz", + "integrity": "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "@types/unist": "^3.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-modify-children": "^4.0.0", + "unist-util-visit-children": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/piccolore": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", + "integrity": "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==", + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/process-ancestry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/process-ancestry/-/process-ancestry-0.1.0.tgz", + "integrity": "sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/radix3": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", + "integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==", + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/recma-build-jsx": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-build-jsx/-/recma-build-jsx-1.0.0.tgz", + "integrity": "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-build-jsx": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-jsx": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/recma-jsx/-/recma-jsx-1.0.1.tgz", + "integrity": "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==", + "license": "MIT", + "dependencies": { + "acorn-jsx": "^5.0.0", + "estree-util-to-js": "^2.0.0", + "recma-parse": "^1.0.0", + "recma-stringify": "^1.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/recma-parse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-parse/-/recma-parse-1.0.0.tgz", + "integrity": "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "esast-util-from-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-stringify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/recma-stringify/-/recma-stringify-1.0.0.tgz", + "integrity": "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-to-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", + "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-recursion": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", + "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", + "license": "MIT", + "dependencies": { + "regex-utilities": "^2.3.0" + } + }, + "node_modules/regex-utilities": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", + "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", + "license": "MIT" + }, + "node_modules/rehype": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/rehype/-/rehype-13.0.2.tgz", + "integrity": "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "rehype-parse": "^9.0.0", + "rehype-stringify": "^10.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-expressive-code": { + "version": "0.44.1", + "resolved": "https://registry.npmjs.org/rehype-expressive-code/-/rehype-expressive-code-0.44.1.tgz", + "integrity": "sha512-+VZgs7Evw4LXRN3owpoBNSTpYuW6GeOdjqcUT1TuY8o/4MGPtbd0EU7Bgrju7X8KrQ6SslOBAuGWJ5fV5TriJQ==", + "license": "MIT", + "dependencies": { + "expressive-code": "^0.44.1" + } + }, + "node_modules/rehype-format": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/rehype-format/-/rehype-format-5.0.1.tgz", + "integrity": "sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-format": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-parse": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/rehype-parse/-/rehype-parse-9.0.1.tgz", + "integrity": "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-html": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-recma": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/rehype-recma/-/rehype-recma-1.0.0.tgz", + "integrity": "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "hast-util-to-estree": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-stringify": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/rehype-stringify/-/rehype-stringify-10.0.1.tgz", + "integrity": "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-html": "^9.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-directive": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/remark-directive/-/remark-directive-4.0.0.tgz", + "integrity": "sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-directive": "^3.0.0", + "micromark-extension-directive": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-mdx": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/remark-mdx/-/remark-mdx-3.1.1.tgz", + "integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==", + "license": "MIT", + "dependencies": { + "mdast-util-mdx": "^3.0.0", + "micromark-extension-mdxjs": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-smartypants": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/remark-smartypants/-/remark-smartypants-3.0.3.tgz", + "integrity": "sha512-gCaK+ndZ0hYezlqFegHFCVh2CQemsi0Npdh1qVM9bxlUFknjkbP6VmojWhddOCrbK0PbbacmYLWfTULRiT1eWA==", + "license": "MIT", + "dependencies": { + "retext": "^9.0.0", + "retext-smartypants": "^6.0.0", + "unified": "^11.0.4", + "unist-util-visit": "^5.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retext": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", + "integrity": "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "retext-latin": "^4.0.0", + "retext-stringify": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-latin": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-latin/-/retext-latin-4.0.0.tgz", + "integrity": "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "parse-latin": "^7.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-smartypants": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/retext-smartypants/-/retext-smartypants-6.2.0.tgz", + "integrity": "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/retext-stringify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/retext-stringify/-/retext-stringify-4.0.0.tgz", + "integrity": "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==", + "license": "MIT", + "dependencies": { + "@types/nlcst": "^2.0.0", + "nlcst-to-string": "^4.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rolldown": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", + "integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.146.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.5", + "@rolldown/binding-android-arm64": "1.2.5", + "@rolldown/binding-darwin-arm64": "1.2.5", + "@rolldown/binding-darwin-x64": "1.2.5", + "@rolldown/binding-freebsd-x64": "1.2.5", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", + "@rolldown/binding-linux-arm64-gnu": "1.2.5", + "@rolldown/binding-linux-arm64-musl": "1.2.5", + "@rolldown/binding-linux-ppc64-gnu": "1.2.5", + "@rolldown/binding-linux-s390x-gnu": "1.2.5", + "@rolldown/binding-linux-x64-gnu": "1.2.5", + "@rolldown/binding-linux-x64-musl": "1.2.5", + "@rolldown/binding-openharmony-arm64": "1.2.5", + "@rolldown/binding-win32-arm64-msvc": "1.2.5", + "@rolldown/binding-win32-x64-msvc": "1.2.5" + } + }, + "node_modules/satteri": { + "version": "0.9.5", + "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.9.5.tgz", + "integrity": "sha512-ZuWVl+vnM64y+/TtX8Kosv2c00W+hLQiiwnEL6H0UKVVrxFqMw4D2CJHHQaouVd89OAhtBBfjWLqhKi3TVUV4w==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.5", + "@types/hast": "^3.0.4", + "@types/mdast": "^4.0.4", + "@types/unist": "^3.0.3" + }, + "optionalDependencies": { + "@bruits/satteri-darwin-arm64": "0.9.5", + "@bruits/satteri-darwin-x64": "0.9.5", + "@bruits/satteri-linux-arm64-gnu": "0.9.5", + "@bruits/satteri-linux-arm64-musl": "0.9.5", + "@bruits/satteri-linux-x64-gnu": "0.9.5", + "@bruits/satteri-linux-x64-musl": "0.9.5", + "@bruits/satteri-wasm32-wasi": "0.9.5", + "@bruits/satteri-win32-arm64-msvc": "0.9.5", + "@bruits/satteri-win32-x64-msvc": "0.9.5" + } + }, + "node_modules/satteri-custom-header-id": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/satteri-custom-header-id/-/satteri-custom-header-id-0.1.0.tgz", + "integrity": "sha512-pqMwdh7B39suLmznT/8wMYWO//zTKwG7JU6TCwHIuAeOSnjxgEsMwj2h/gP7nEK/eIe40SXgFiC6QHcEBsPm5A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ocavue" + }, + "peerDependencies": { + "satteri": "^0.9.0" + }, + "peerDependenciesMeta": { + "satteri": { + "optional": true + } + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/server-dom-shim": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/server-dom-shim/-/server-dom-shim-1.1.0.tgz", + "integrity": "sha512-oyKhBZtkr/SGB9YE2r0VtQxQCxaVx/Ix1fMz0XMd6K4T1/TMfDs9K2GR9QjpUtD+siyeLXr+3CzzGSvhTI1sEw==", + "license": "MIT", + "dependencies": { + "@lit-labs/ssr-dom-shim": "^1.5.0" + }, + "funding": { + "url": "https://github.com/sponsors/ocavue" + } + }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/shiki": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz", + "integrity": "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==", + "license": "MIT", + "dependencies": { + "@shikijs/core": "4.4.3", + "@shikijs/engine-javascript": "4.4.3", + "@shikijs/engine-oniguruma": "4.4.3", + "@shikijs/langs": "4.4.3", + "@shikijs/themes": "4.4.3", + "@shikijs/types": "4.4.3", + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/shiki-twoslash-renderer": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/shiki-twoslash-renderer/-/shiki-twoslash-renderer-0.3.5.tgz", + "integrity": "sha512-V1d8JzkhIIJ1n7ZboZmGcPCBREZTkfdF9xIMgVOR71MyWVzvqyFre7sVRTmt7T/VjN0phtOgfMX7JChcaBp5Rg==", + "license": "MIT", + "dependencies": { + "@aria-ui/core": "^0.2.1", + "@aria-ui/elements": "^0.1.10", + "@shikijs/twoslash": "^4.1.0", + "@shikijs/types": "^4.1.0", + "@types/hast": "^3.0.4", + "twoslash": "^0.3.8" + }, + "funding": { + "url": "https://github.com/sponsors/ocavue" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/sitemap": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/sitemap/-/sitemap-9.0.1.tgz", + "integrity": "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==", + "license": "MIT", + "dependencies": { + "@types/node": "^24.9.2", + "@types/sax": "^1.2.1", + "arg": "^5.0.0", + "sax": "^1.4.1" + }, + "bin": { + "sitemap": "dist/esm/cli.js" + }, + "engines": { + "node": ">=20.19.5", + "npm": ">=10.8.2" + } + }, + "node_modules/smol-toml": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/starlight-theme-nova": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/starlight-theme-nova/-/starlight-theme-nova-0.12.2.tgz", + "integrity": "sha512-ElILXQ4d+wtQV6eMrWVcJpSParodVc6zJge+OCk6ArAC64u767gznV1QKFe+qfkZltIecIsAlwhQezrFNKsAzA==", + "license": "MIT", + "dependencies": { + "@aria-ui/core": "^0.2.1", + "@aria-ui/utils": "^0.1.7", + "@astrojs/markdown-satteri": "^0.3.4", + "@pagefind/default-ui": "^1.5.2", + "@shikijs/transformers": "^4.3.1", + "@shikijs/twoslash": "^4.3.1", + "@shikijs/types": "^4.3.1", + "@types/hast": "^3.0.5", + "astro-theme-toggle": "^0.8.1", + "hast-util-is-element": "^3.0.0", + "rehype": "^13.0.2", + "satteri-custom-header-id": "^0.1.0", + "shiki-twoslash-renderer": "^0.3.5" + }, + "funding": { + "url": "https://github.com/sponsors/ocavue" + }, + "peerDependencies": { + "@astrojs/starlight": "*" + }, + "peerDependenciesMeta": { + "@astrojs/starlight": { + "optional": true + } + } + }, + "node_modules/stream-replace-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stream-replace-string/-/stream-replace-string-2.0.0.tgz", + "integrity": "sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==", + "license": "MIT" + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "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==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/svgo": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.2.tgz", + "integrity": "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng==", + "license": "MIT", + "dependencies": { + "commander": "^11.1.0", + "css-select": "^5.1.0", + "css-tree": "^3.0.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.1.1", + "sax": "^1.5.0" + }, + "bin": { + "svgo": "bin/svgo.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/svgo/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", + "license": "MIT" + }, + "node_modules/tinyclip": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.15.tgz", + "integrity": "sha512-uo33abH+Ays0xYaDysoBt494Hb3hsEczMpcC0MwFl773pazORx4fmvKhclhR1wonUbB6vvpRsvVMwnhfqeMc+A==", + "license": "MIT", + "engines": { + "node": "^16.14.0 || >= 17.3.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/twoslash": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/twoslash/-/twoslash-0.3.9.tgz", + "integrity": "sha512-rDclk+OtzuTX+tnea7DYLCkqGQ3eP0IyfD+kzUJ7t46X/NzlaxwrhecmEBNuSCuEn3V+n1PhcjUUQQ7gUJzX5Q==", + "license": "MIT", + "dependencies": { + "@typescript/vfs": "^1.6.4", + "twoslash-protocol": "0.3.9" + }, + "peerDependencies": { + "typescript": "^5.5.0 || ^6.0.0" + } + }, + "node_modules/twoslash-protocol": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/twoslash-protocol/-/twoslash-protocol-0.3.9.tgz", + "integrity": "sha512-9/iwp+CXOnjFMPQuPL5PkuRbZnDoNpBvtJCLs9t8kDYkL3YHujbvnHfZA1i5fApDftVEdBw+T/4F+dH5kIzpYQ==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "license": "MIT" + }, + "node_modules/ultrahtml": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ultrahtml/-/ultrahtml-1.7.0.tgz", + "integrity": "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==", + "license": "MIT" + }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/unenv": { + "version": "2.0.0-rc.24", + "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unifont": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/unifont/-/unifont-0.7.5.tgz", + "integrity": "sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.1.0", + "ohash": "^2.0.11", + "undici": "^8.0.0" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-modify-children": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-modify-children/-/unist-util-modify-children-4.0.0.tgz", + "integrity": "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "array-iterate": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position-from-estree": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position-from-estree/-/unist-util-position-from-estree-2.0.0.tgz", + "integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-children": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit-children/-/unist-util-visit-children-3.0.0.tgz", + "integrity": "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unstorage": { + "version": "1.17.5", + "resolved": "https://registry.npmjs.org/unstorage/-/unstorage-1.17.5.tgz", + "integrity": "sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==", + "license": "MIT", + "dependencies": { + "anymatch": "^3.1.3", + "chokidar": "^5.0.0", + "destr": "^2.0.5", + "h3": "^1.15.10", + "lru-cache": "^11.2.7", + "node-fetch-native": "^1.6.7", + "ofetch": "^1.5.1", + "ufo": "^1.6.3" + }, + "peerDependencies": { + "@azure/app-configuration": "^1.8.0", + "@azure/cosmos": "^4.2.0", + "@azure/data-tables": "^13.3.0", + "@azure/identity": "^4.6.0", + "@azure/keyvault-secrets": "^4.9.0", + "@azure/storage-blob": "^12.26.0", + "@capacitor/preferences": "^6 || ^7 || ^8", + "@deno/kv": ">=0.9.0", + "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", + "@planetscale/database": "^1.19.0", + "@upstash/redis": "^1.34.3", + "@vercel/blob": ">=0.27.1", + "@vercel/functions": "^2.2.12 || ^3.0.0", + "@vercel/kv": "^1 || ^2 || ^3", + "aws4fetch": "^1.0.20", + "db0": ">=0.2.1", + "idb-keyval": "^6.2.1", + "ioredis": "^5.4.2", + "uploadthing": "^7.4.4" + }, + "peerDependenciesMeta": { + "@azure/app-configuration": { + "optional": true + }, + "@azure/cosmos": { + "optional": true + }, + "@azure/data-tables": { + "optional": true + }, + "@azure/identity": { + "optional": true + }, + "@azure/keyvault-secrets": { + "optional": true + }, + "@azure/storage-blob": { + "optional": true + }, + "@capacitor/preferences": { + "optional": true + }, + "@deno/kv": { + "optional": true + }, + "@netlify/blobs": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/blob": { + "optional": true + }, + "@vercel/functions": { + "optional": true + }, + "@vercel/kv": { + "optional": true + }, + "aws4fetch": { + "optional": true + }, + "db0": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "ioredis": { + "optional": true + }, + "uploadthing": { + "optional": true + } + } + }, + "node_modules/url-extras": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/url-extras/-/url-extras-0.1.0.tgz", + "integrity": "sha512-8tzwTeXFPuX/5PHuCDQE5Dd9Ts4rwoq2t9aIT+HS4iAVpmj5l4Ao7Q+BuuFjvWRqrLswBhQDk8O96ZicgCqQqw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/workerd": { + "version": "1.20260820.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260820.1.tgz", + "integrity": "sha512-/wk4rFNHH6IVMXFe6aPEZsT5YWpWtfKTUMt7+rFkyeGbXcGCy4yxODmJatbqYj0jmqHETdz0+m2mT+vNjXnF7w==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260820.1", + "@cloudflare/workerd-darwin-arm64": "1.20260820.1", + "@cloudflare/workerd-linux-64": "1.20260820.1", + "@cloudflare/workerd-linux-arm64": "1.20260820.1", + "@cloudflare/workerd-windows-64": "1.20260820.1" + } + }, + "node_modules/wrangler": { + "version": "4.125.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.125.0.tgz", + "integrity": "sha512-yFpvggu+xk1Hdm/Uxwaqa19bb7GArME4CrCS3Vov68a2TZq2MPO+wLocKbbnIC9K0oLowcdau7/ycxbbNHKCEg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260820.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260820.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260820.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/wrangler/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/wrangler/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "license": "MIT" + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/youch": { + "version": "4.1.0-beta.10", + "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/colors": "^4.1.5", + "@poppinss/dumper": "^0.6.4", + "@speed-highlight/core": "^1.2.7", + "cookie": "^1.0.2", + "youch-core": "^0.3.3" + } + }, + "node_modules/youch-core": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@poppinss/exception": "^1.2.2", + "error-stack-parser-es": "^1.0.5" + } + }, + "node_modules/youch/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/src/Docs/package.json b/src/Docs/package.json new file mode 100644 index 0000000..d6284ca --- /dev/null +++ b/src/Docs/package.json @@ -0,0 +1,22 @@ +{ + "name": "@stellarwp/foundation-docs", + "private": true, + "type": "module", + "engines": { + "node": ">=24 <25" + }, + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview", + "preview:cloudflare": "npm run build && wrangler pages dev dist" + }, + "dependencies": { + "@astrojs/starlight": "^0.41.7", + "astro": "^7.2.4", + "starlight-theme-nova": "^0.12.2" + }, + "devDependencies": { + "wrangler": "^4.125.0" + } +} diff --git a/src/Docs/public/favicon.svg b/src/Docs/public/favicon.svg new file mode 100644 index 0000000..fbd51b5 --- /dev/null +++ b/src/Docs/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/Docs/src/content.config.ts b/src/Docs/src/content.config.ts new file mode 100644 index 0000000..e54e72f --- /dev/null +++ b/src/Docs/src/content.config.ts @@ -0,0 +1,8 @@ +import { defineCollection } from 'astro:content'; +import { docsLoader, i18nLoader } from '@astrojs/starlight/loaders'; +import { docsSchema, i18nSchema } from '@astrojs/starlight/schema'; + +export const collections = { + docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }), + i18n: defineCollection({ loader: i18nLoader(), schema: i18nSchema() }), +}; diff --git a/src/Docs/src/content/docs/components/container.mdx b/src/Docs/src/content/docs/components/container.mdx new file mode 100644 index 0000000..2091dd6 --- /dev/null +++ b/src/Docs/src/content/docs/components/container.mdx @@ -0,0 +1,216 @@ +--- +title: Container +description: Autowire application services, select implementations, and compose features through service providers. +sidebar: + order: 1 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +Foundation Container adapts [DI52](https://github.com/lucatume/di52) behind a shared container contract and service provider base class. Use it to describe how application services are constructed while keeping dependency resolution out of the services themselves. + +## Installation + +Install the split package in applications that define their own container or service providers: + +```shell +composer require stellarwp/foundation-container +``` + +Other Foundation packages install Container automatically when they depend on it. Composer does not require a second explicit installation in that case. + +### Prepare the application + +Create one container in the application composition root and register providers in dependency order. These guides establish that structure: + + + + + + + +## Usage + +### Let the container autowire concrete classes + +The container can construct an unbound concrete class when its constructor dependencies are also concrete classes: + +```php +final readonly class Catalog_Synchronizer { + + public function __construct( + private Product_Repository $products, + private Remote_Catalog $catalog + ) { + } +} +``` + +Resolve the application entrypoint where it is needed: + +```php +$synchronizer = $container->get( Catalog_Synchronizer::class ); +``` + +Prefer constructor injection throughout application code. Calling `get()` inside a service hides its dependencies and turns the container into a service locator. + +### Select an interface implementation + +In `src/Catalog/Provider.php`, bind an interface when the container cannot infer which implementation the application wants. Use `bind()` for a new instance on each resolution and `singleton()` when every resolution should return the same instance: + +```php title="Provider.php" +register_catalog(); + } + + private function register_catalog(): void { + $this->container->singleton( + Catalog::class, + Remote_Catalog::class + ); + } +} +``` + +Bindings are lazy. Registering `Remote_Catalog` does not construct it; the container builds it when another service first requests `Catalog`. + +### Supply configuration and scalar values + +In the same `src/Catalog/Provider.php`, use a contextual binding when one class needs a scalar or a feature-specific implementation. Target scalar constructor arguments by their `$name`. Import `lucatume\DI52\Container as C` when a factory callback must resolve another service: + +```php title="Provider.php" +private function register_catalog(): void { + $this->container->when( Remote_Catalog::class ) + ->needs( '$endpoint' ) + ->give( (string) $this->config->get( 'catalog.endpoint' ) ); + + $this->container->singleton( Remote_Catalog::class ); + $this->container->singleton( + Catalog::class, + static fn ( C $c ): Remote_Catalog => $c->get( Remote_Catalog::class ) + ); +} +``` + +The callback aliases `Catalog` to the configured `Remote_Catalog` singleton. This preserves the contextual bindings registered for the concrete class and ensures both identifiers resolve the same object. + +Use a factory callback only when the value must be computed or fetched from the container. Let the container construct the complete service whenever it can. + +### Build a collection across providers + +In `src/Report/Provider.php`, use `mergeArrayVar()` when independent providers contribute to one ordered collection. The provider that owns the collection registers its default and supplies it to the consuming class: + +```php title="Provider.php" +public const string EXPORTERS = 'your-plugin.report.exporters'; + +private function register_exporter_collection(): void { + $this->container->mergeArrayVar( self::EXPORTERS, [] ); + + $this->container->when( Exporter_Collection::class ) + ->needs( '$exporters' ) + ->give( static fn ( C $c ): array => $c->get( self::EXPORTERS ) ); +} +``` + +:::note[Name shared container identifiers] +Use class and interface names as container identifiers for services. Introduce a text identifier only when a provider owns a shared scalar, configured object, or contribution point that has no natural service contract. + +Application-owned text identifiers should start with a stable application or plugin prefix, followed by the feature and value name, as shown by `your-plugin.report.exporters` above. This keeps ownership visible and prevents unrelated features from choosing the same key. Keep the identifier stable if the provider class is renamed. + +Text container identifiers are internal application wiring. Do not reuse them as configuration paths, database tables, cache keys, lock names, commands, or other externally visible values. +::: + +Other feature providers append their implementations without replacing earlier contributions. For example, `src/Report/Csv/Provider.php` can contribute the CSV implementation: + +```php title="Provider.php" +private function register_csv_exporter(): void { + $this->container->mergeArrayVar( + Report\Provider::EXPORTERS, + static fn ( C $c ): array => [ + $c->get( Csv_Exporter::class ), + ] + ); +} +``` + +:::caution[Complete registration before resolving the collection] +Register every contributing provider before resolving services that consume the collection. An existing service will not be rebuilt when a later provider adds another contribution. +::: + +### Register lazy WordPress callbacks + +In `src/Catalog/Provider.php`, use `callback()` to let WordPress resolve a service only when its hook runs: + +```php title="Provider.php" +private function register_catalog_sync(): void { + $this->container->singleton( Catalog_Synchronizer::class ); + + add_action( + 'your_plugin/sync_catalog', + $this->container->callback( Catalog_Synchronizer::class, 'synchronize' ) + ); +} +``` + +This avoids constructing the synchronizer during every request merely to register its callback. + +### Decorate a service + +In `src/Catalog/Provider.php`, use a decorator chain when cross-cutting behavior should wrap a service without changing its implementation. List the outermost decorator first and the base implementation last: + +```php title="Provider.php" +private function register_catalog(): void { + $this->container->singletonDecorators( + Catalog::class, + [ + Logging_Catalog::class, + Caching_Catalog::class, + Remote_Catalog::class, + ] + ); +} +``` + +Resolving `Catalog` returns one `Logging_Catalog` that wraps `Caching_Catalog`, which wraps `Remote_Catalog`. Use `bindDecorators()` instead when the application needs a new chain on every resolution. + +## Testing + +### Replace an implementation in a focused test + +Bind a test double to the same contract before resolving the class under test: + +```php +$catalog = new Fake_Catalog(); + +$this->container->bind( Catalog::class, $catalog ); + +$synchronizer = $this->container->get( Catalog_Synchronizer::class ); +$synchronizer->synchronize(); + +$this->assertTrue( $catalog->was_synchronized() ); +``` + +Test application services through their public behavior. Reserve container integration tests for provider graphs where the binding itself is the behavior under test. diff --git a/src/Docs/src/content/docs/components/database.mdx b/src/Docs/src/content/docs/components/database.mdx new file mode 100644 index 0000000..9a0d503 --- /dev/null +++ b/src/Docs/src/content/docs/components/database.mdx @@ -0,0 +1,142 @@ +--- +title: Database +description: WordPress-backed migrations, queries, and distributed locks built on wpdb and dbDelta. +sidebar: + order: 2 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +Foundation Database provides three focused capabilities for WordPress applications: versioned schema migrations, a small inspectable query API, and a database-backed implementation of the Foundation Lock contract. It intentionally builds on `wpdb`, `dbDelta()`, and WordPress table prefixes instead of acting as a generic database abstraction. + +## Installation + +Install the runtime package in the application: + +```shell +composer require stellarwp/foundation-database +``` + +Foundation Database installs its Container, Lock, and WP-CLI runtime dependencies automatically. Install `stellarwp/foundation-cli` separately with `--dev` only when the project uses its migration and table generators. + +:::note[Runtime requirements] +Foundation Database requires WordPress 6.2 or newer because prepared identifier placeholders use `%i`. Database locks require MySQL 5.6.4 or newer, or MariaDB 5.3 or newer, for fractional-second timestamps. +::: + +### Prepare the application + +Database services use the application's existing container, provider graph, Foundation prefix, and WP-CLI command prefix: + + + + + + + + +## Configuration + +### Scope database resources + +Set a stable application prefix in the root `config.php` for a standalone plugin: + +```php title="config.php" + [ + 'prefix' => 'your-plugin', + ], +]; +``` + +With this prefix, Foundation uses these resources by default: + +| Resource | Default | +| --- | --- | +| Migration table | `your_plugin_foundation_migrations` | +| Lock table | `your_plugin_foundation_locks` | +| Migration lock | `your-plugin-foundation-database-migrations` | +| WP-CLI command | `wp your-plugin migrate` | + +Complete WordPress applications that own the entire installation can keep the zero-configuration `nx` prefix. Standalone plugins should not share the default because another Foundation consumer could otherwise read the same migration ledger or contend for the same lock. + +Keep the prefix stable after migrations have run. Changing it points the application at a different ledger and lock table, making every configured migration appear pending. + +### Override individual resources + +Package-specific settings in the root `config.php` override values derived from `foundation.prefix`: + +```php title="config.php" +return [ + 'foundation' => [ + 'prefix' => 'your-plugin', + ], + 'database' => [ + 'migrations_table' => $_ENV['FOUNDATION_DATABASE_MIGRATIONS_TABLE'] ?? '', + 'locks_table' => $_ENV['FOUNDATION_DATABASE_LOCKS_TABLE'] ?? '', + 'lock_name' => $_ENV['FOUNDATION_DATABASE_LOCK_NAME'] ?? null, + 'lock_ttl' => (int) ( $_ENV['FOUNDATION_DATABASE_LOCK_TTL'] ?? 300 ), + ], +]; +``` + +Leave table names empty to use the scoped defaults. An overridden table name is the complete physical name and must include the WordPress table prefix itself. All physical table names must fit MySQL's 64-character identifier limit. + +The migration lock settings coordinate migration execution only. Applications selecting `DatabaseLock` for their own work choose each lock name and TTL when calling `acquire()`. + +### Register the providers + +In `src/App.php`, register `WPCliProvider` before `DatabaseProvider`, then register application providers that contribute migrations or select the database lock: + +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Providable; +use StellarWP\Foundation\Database; +use StellarWP\Foundation\WPCli; +use YourPlugin\Database as ApplicationDatabase; + +/** @var list> */ +private const array PROVIDERS = [ + WPCli\WPCliProvider::class, + Database\DatabaseProvider::class, + ApplicationDatabase\Provider::class, +]; +``` + +`DatabaseProvider` configures `wpdb`, schema services, migration storage, the migration lock, and the `migrate` command. It does not create tables, run migrations, or select `DatabaseLock` as the application's general `Lock` implementation during WordPress bootstrap. + +## Choose a feature + + + + + + diff --git a/src/Docs/src/content/docs/components/database/lock.mdx b/src/Docs/src/content/docs/components/database/lock.mdx new file mode 100644 index 0000000..0f339e0 --- /dev/null +++ b/src/Docs/src/content/docs/components/database/lock.mdx @@ -0,0 +1,104 @@ +--- +title: Database Lock +description: Use the WordPress database as the shared backend for Foundation lock ownership. +sidebar: + order: 3 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +`DatabaseLock` implements the shared Foundation `Lock` contract with a WordPress table. Choose it when every process that must coordinate can reach the same primary database and a dedicated Redis service is unnecessary. + + + + + + +## Configuration + +### Select the database implementation + +`DatabaseProvider` registers `DatabaseLock`, but does not choose it as the application's global `Lock` implementation. Make that application-level decision in `src/Lock/Provider.php`: + +```php title="Provider.php" +register_lock(); + } + + private function register_lock(): void { + $this->container->singleton( + Lock::class, + static fn ( C $c ): DatabaseLock => $c->get( DatabaseLock::class ) + ); + } +} +``` + +The factory aliases the interface to the `DatabaseLock` singleton already owned by `DatabaseProvider`. A direct concrete binding would ask the container to construct a separate instance. + +Register providers in dependency order in `src/App.php`: + +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Providable; +use StellarWP\Foundation\Database\DatabaseProvider; +use StellarWP\Foundation\WPCli\WPCliProvider; +use YourPlugin\Lock; + +/** @var list> */ +private const array PROVIDERS = [ + WPCliProvider::class, + DatabaseProvider::class, + Lock\Provider::class, +]; +``` + +### Initialize lock storage + +Create or reconcile the database lock table during deployment: + +```shell +wp your-plugin migrate --initialize +``` + +Replace `your-plugin` with the configured command prefix. The command is idempotent, so deployment automation can run it before migrations without first checking whether the table exists. + +## Usage + +Inject the shared `Lock` contract into application services. Acquisition, release, refresh, contention, long-running leases, and remote idempotency are covered in the [Lock guide](/components/lock/). + +Database-backed locks have these operational constraints: + +- Lock names must fit within 191 bytes. +- Expiration uses the database's UTC clock, keeping ownership consistent between PHP processes. +- Every contender must read and write through the same primary database. Replica reads can report stale ownership. +- The TTL must exceed the protected operation, or the owner must refresh the token before it expires. + +:::caution[Keep database and remote guarantees separate] +A database lock prevents cooperating application processes from overlapping while its lease is valid. It cannot guarantee exactly-once behavior in a remote payment or messaging API. Use the remote system's idempotency key as well. +::: + +## Testing + +Use `InMemoryLock` in unit and feature tests that only verify application behavior against the shared contract. Use `wpunit` when testing `DatabaseLock` itself or behavior that depends on real lock-table queries and database time. diff --git a/src/Docs/src/content/docs/components/database/migrations.mdx b/src/Docs/src/content/docs/components/database/migrations.mdx new file mode 100644 index 0000000..b116af2 --- /dev/null +++ b/src/Docs/src/content/docs/components/database/migrations.mdx @@ -0,0 +1,214 @@ +--- +title: Migrations +description: Define WordPress tables, register versioned schema changes, and run them safely during deployment. +sidebar: + order: 1 +--- + +import { LinkCard } from '@astrojs/starlight/components'; + +Foundation migrations apply ordered database changes and record each successful run in a WordPress-backed ledger. Prefer the bundled WP-CLI command during deployment so initialization, locking, execution, and status reporting follow one path. + + + +## Create a migration + +### Generate the database feature + +Install the generator as a development dependency: + +```shell +composer require --dev stellarwp/foundation-cli +``` + +Generate the application provider before its tables and migrations: + +```shell +vendor/bin/foundation make:database-provider +vendor/bin/foundation make:database-table Reports_Table +vendor/bin/foundation make:database-migration Create_Reports_Table +``` + +The generators use the project's Composer namespace and create this feature structure by default: + +```text +src/Database/ + Provider.php + Migrations/ + Create_Reports_Table.php + Tables/ + Reports_Table.php +``` + +When `src/Database/Provider.php` exists, the table and migration generators add their container registrations automatically. Register that provider in the application's ordered provider list as shown in [Database configuration](/components/database/#register-the-providers). + +Project-specific stubs can override the defaults at: + +```text +foundation/stubs/database/provider.stub +foundation/stubs/database/table.stub +foundation/stubs/database/table-migration.stub +foundation/stubs/database/migration.stub +``` + +### Define the table + +The generated `src/Database/Tables/Reports_Table.php` owns its physical name and desired schema. `Database::tableName()` applies the current WordPress table prefix. + +```php title="Reports_Table.php" +database->tableName( self::TABLE ); + } + + public function definition(): TableDefinition { + return TableDefinition::for( $this ) + ->bigIncrements( 'id' ) + ->string( 'status', 20 )->default( 'draft' ) + ->longText( 'payload' ) + ->dateTime( 'created_at' ) + ->dateTime( 'updated_at' )->nullable() + ->index( 'status', 'status' ); + } +} +``` + +### Apply the table definition + +The generated `src/Database/Migrations/Create_Reports_Table.php` passes the table object to `Schema`. The schema service uses `dbDelta()` and verifies the resulting definition before the migration is recorded as successful. + +```php title="Create_Reports_Table.php" +createOrUpdate( $this->table ); + } + + public function down( Schema $schema ): void { + $schema->drop( $this->table ); + } +} +``` + +Migration IDs are permanent, byte-exact identifiers. The generator prefixes them with a sortable timestamp so migration history is easy to inspect, but execution follows provider contribution order rather than sorting by ID. Register providers and migrations in dependency order, and do not change an ID after the migration has been deployed. + +For later schema changes, update the table's desired definition and create a new migration that applies it. Use `Schema::execute()` for data changes or schema operations that `dbDelta()` cannot express reliably. + +:::caution[Make rollback behavior deliberate] +The generic migration stub throws `IrreversibleMigration` from `down()`. Implement a safe inverse before relying on rollback, or keep the migration explicitly irreversible. Foundation does not pretend a destructive data change can be undone. +::: + +## Run migrations + +### Initialize migration storage + +Create or reconcile Foundation's migration ledger and lock table before running migrations: + +```shell +wp your-plugin migrate --initialize +``` + +Run this idempotent command during every deployment. Replace `your-plugin` with the configured command prefix; applications using the default prefix run `wp nx migrate --initialize`. + +### Apply pending migrations + +```shell +wp your-plugin migrate --run +``` + +Running the command without an operation displays migration status: + +```shell +wp your-plugin migrate +``` + +The runner acquires the configured migration lock, executes pending migrations in provider contribution order, and records each successful migration in one batch. + +### Roll back or rebuild + +Roll back the latest applied batch: + +```shell +wp your-plugin migrate --rollback +``` + +Roll back every configured migration and run them again: + +```shell +wp your-plugin migrate --refresh --yes +``` + +Drop only Foundation's migration ledger when intentionally resetting migration history: + +```shell +wp your-plugin migrate --drop-store --yes +``` + +:::danger[`--drop-store` does not drop application tables] +This removes migration history, not tables created by migrations and not the Foundation lock table. The next `--run` treats every configured migration as pending, so use it only when the remaining schema is compatible with reapplying that history. +::: + +## Run migrations from PHP + +WP-CLI is the preferred deployment interface. For controlled environments that cannot invoke WP-CLI, resolve the same `Migrator` service from the application container: + +```php +use StellarWP\Foundation\Database\Migration\Migrator; + +$migrator = $container->get( Migrator::class ); + +$migrator->initialize(); +$result = $migrator->run(); +``` + +The programmatic API follows the same ledger and lock rules as the command. Do not run migrations during every normal WordPress request. + +## Testing + +Use `wpunit` tests for table definitions, schema reconciliation, and migrations that execute against WordPress. Use `integration` when the test proves contributions from multiple providers, and use `wpcli` for the real migration command lifecycle. + +Create and remove application tables within the test lifecycle so tests exercise the real `wpdb` and `dbDelta()` behavior rather than a PHP fake. diff --git a/src/Docs/src/content/docs/components/database/query-builder.mdx b/src/Docs/src/content/docs/components/database/query-builder.mdx new file mode 100644 index 0000000..0bb49c8 --- /dev/null +++ b/src/Docs/src/content/docs/components/database/query-builder.mdx @@ -0,0 +1,166 @@ +--- +title: Query Builder +description: Read and write WordPress tables through a small query API with inspectable SQL and bindings. +sidebar: + order: 2 +--- + +import { LinkCard } from '@astrojs/starlight/components'; + +Foundation Database wraps common `wpdb` operations with prepared bindings, quoted identifiers, consistent exceptions, and a small fluent query builder. It remains intentionally close to SQL so developers can inspect exactly what WordPress will execute. + + + +## Read rows + +Inject the `Database` contract and the table object into the class that owns the query. For example, create `src/Report/Report_Repository.php`: + +```php title="Report_Repository.php" +> + */ + public function published( int $limit = 100 ): array { + return $this->database + ->table( $this->table, 'r' ) + ->select( 'r.id', 'r.status', 'r.payload', 'r.created_at' ) + ->where( 'r.status', '=', 'published' ) + ->orderBy( 'r.created_at', 'DESC' ) + ->limit( $limit ) + ->get(); + } +} +``` + +`first()` returns one row or `null`. `get()` returns a list of associative rows. Qualified identifiers such as `r.created_at` are quoted as `` `r`.`created_at` `` rather than as one identifier. + +Use `null` with equality operators for SQL null checks: + +```php +$unpublished = $this->database + ->table( $this->table ) + ->where( 'published_at', '=', null ) + ->get(); + +$published = $this->database + ->table( $this->table ) + ->where( 'published_at', '!=', null ) + ->get(); +``` + +These comparisons compile to `IS NULL` and `IS NOT NULL`. Other operators with `null` are rejected because they do not have useful SQL semantics. + +## Write rows + +Use the table object for inserts, updates, and deletes so physical table naming stays in one place: + +```php +$reportId = $this->database->insertGetId( $this->table, [ + 'status' => 'draft', + 'payload' => wp_json_encode( $payload ), + 'created_at' => current_time( 'mysql', true ), +] ); + +$updated = $this->database->update( + $this->table, + [ + 'status' => 'published', + 'updated_at' => current_time( 'mysql', true ), + ], + [ 'id' => $reportId ] +); + +$deleted = $this->database->delete( $this->table, [ 'id' => $reportId ] ); +``` + +`insert()` returns the affected row count, while `insertGetId()` returns the generated integer ID. `update()`, `delete()`, and `execute()` return affected row counts. + +## Inspect and execute SQL + +Build a query before executing it when logging or diagnostics need the SQL shape and separate bindings: + +```php +$query = $this->database + ->table( $this->table, 'r' ) + ->select( 'r.id', 'r.status' ) + ->where( 'r.status', '=', 'failed' ) + ->limit( 25 ) + ->query(); + +$sql = $query->toSql(); +$bindings = $query->bindings(); +$preparedSql = $query->toPreparedSql(); +$rows = $query->get(); +``` + +Prefer `toSql()` plus `bindings()` for structured diagnostics. A fully prepared SQL string may contain customer or application data and should not be logged without considering its sensitivity. + +The `Database` contract also exposes prepared low-level operations for queries that do not fit the builder: + +```php +$row = $this->database->row( + 'SELECT * FROM %i WHERE id = %d', + $this->table->name(), + $reportId +); + +$count = $this->database->value( + 'SELECT COUNT(*) FROM %i WHERE status = %s', + $this->table->name(), + 'published' +); + +$affected = $this->database->execute( + 'UPDATE %i SET status = %s WHERE status = %s', + $this->table->name(), + 'archived', + 'published' +); +``` + +Use `prepare()` when another WordPress API requires the prepared SQL string. Keep values in placeholders instead of concatenating untrusted input. + +## Handle query failures + +Database operations throw `QueryException` when `wpdb` reports an error. The exception retains the SQL template, bindings, and database error separately: + +```php +use Psr\Log\LoggerInterface; +use StellarWP\Foundation\Database\Exceptions\QueryException; + +try { + $rows = $query->get(); +} catch ( QueryException $exception ) { + $this->logger->error( 'Report query failed.', [ + 'sql' => $exception->sql(), + 'bindings' => $exception->bindings(), + 'database_error' => $exception->databaseError(), + ] ); + + throw $exception; +} +``` + +Avoid exposing database errors or bindings to end users. They may contain schema details or sensitive values. + +## Testing + +Use `wpunit` tests for repositories and query behavior. Create the real table, exercise the real WordPress database, and remove the table during cleanup. This catches placeholder, collation, identifier, and MariaDB behavior that a mocked `wpdb` cannot reproduce. diff --git a/src/Docs/src/content/docs/components/identifier.mdx b/src/Docs/src/content/docs/components/identifier.mdx new file mode 100644 index 0000000..33f3c01 --- /dev/null +++ b/src/Docs/src/content/docs/components/identifier.mdx @@ -0,0 +1,251 @@ +--- +title: Identifier +description: Generate and validate injectable ULIDs without coupling application services to static helpers. +sidebar: + order: 5 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +Foundation Identifier provides injectable contracts for string identifiers and a default ULID implementation. Generated ULIDs are canonical 26-character uppercase strings that combine a millisecond timestamp with secure randomness. + +ULIDs work well for identifiers that must be portable across databases or systems while remaining roughly sortable by creation time. + +## Installation + +Install the split package: + +```shell +composer require stellarwp/foundation-identifier +``` + +### Prepare the application + +Identifier services are registered through the shared application provider list: + + + + + + + +## Configuration + +### Register the identifier provider + +In `src/App.php`, add the Foundation provider before features that generate or validate ULIDs: + +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Providable; +use StellarWP\Foundation\Identifier\IdentifierProvider; +use YourPlugin\Job; + +/** @var list> */ +private const array PROVIDERS = [ + IdentifierProvider::class, + Job\Provider::class, +]; +``` + +The provider registers secure entropy, a system millisecond clock, `UlidGenerator`, and `UlidValidator` as shared services. + +### Choose the contract your feature needs + +Use the narrowest contract that describes the feature: + +| Contract | Use when | +| --- | --- | +| `Ulid\Contracts\UlidGenerator` | The stored or exchanged identifier must be a ULID | +| `Contracts\IdentifierGenerator` | The feature needs a unique string but should not choose its format | + +`IdentifierProvider` binds the ULID-specific contract. It deliberately does not bind the broad `IdentifierGenerator` contract because the application must decide whether ULID is its default identifier strategy. + +If the application chooses ULIDs as its default, create `src/Identifier/Provider.php`: + +```php title="Provider.php" +register_default_generator(); + } + + private function register_default_generator(): void { + $this->container->bind( + IdentifierGenerator::class, + static fn ( C $c ): UlidGenerator => $c->get( UlidGenerator::class ) + ); + } +} +``` + +Register both providers in `src/App.php`, in that order: + +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Providable; +use StellarWP\Foundation\Identifier\IdentifierProvider; +use YourPlugin\Identifier; + +/** @var list> */ +private const array PROVIDERS = [ + IdentifierProvider::class, + Identifier\Provider::class, +]; +``` + +The callback aliases the broad contract to the configured ULID singleton, so both contracts resolve the same generator. + +## Usage + +### Generate the application's default identifier + +In `src/Job/Job_Creator.php`, inject the broad contract when the feature needs a unique string but does not own its format. With the application binding above, it resolves to the ULID generator: + +```php title="Job_Creator.php" +generator->generate(); + } +} +``` + +A generated value looks like `01ARYZ6S410000000000000000`. + +If a database column, message contract, or remote API specifically requires a ULID, inject `Ulid\Contracts\UlidGenerator` instead. That type makes the format requirement explicit and does not require the broad application binding. + +:::caution[Do not replace failed secure generation with a weaker identifier] +Generation fails when secure entropy is unavailable, the clock is outside the ULID timestamp range, or an injected entropy source returns the wrong number of bytes. Abort or retry the operation. Do not fall back to `uniqid()`, a timestamp, or another predictable value that weakens identifier guarantees. +::: + +### Validate external ULIDs + +In `src/Job/Job_Request.php`, use `UlidValidator` at input boundaries before passing an external identifier into application behavior: + +```php title="Job_Request.php" +validator->isValid( $value ) ) { + throw new InvalidArgumentException( 'The job identifier is invalid.' ); + } + + return $value; + } +} +``` + +Validation accepts canonical uppercase ULIDs only. Lowercase values, invalid lengths, ambiguous characters such as `I`, `L`, `O`, and `U`, and timestamps outside the ULID range are rejected. + +### Understand ordering and exposure + +The first ten ULID characters encode creation time in milliseconds, so sorting canonical ULID strings groups identifiers by generation time. + +:::caution[ULIDs are not a total sequence] +The default generator uses random entropy and is not monotonic. Two identifiers generated within the same millisecond are valid but are not guaranteed to sort in generation order. Use a database sequence or another explicit ordering field when exact order matters. +::: + +:::caution[ULIDs are identifiers, not secrets] +A ULID exposes its approximate creation time. Never use possession of an identifier as authorization, and do not place secrets in or derive secrets from it. Continue to enforce normal capability and ownership checks when loading the identified resource. +::: + +## Testing + +### Replace format-agnostic generation + +When application code depends on `IdentifierGenerator`, use a small fixture that always returns a known value. Create `tests/Support/Fixtures/Identifier/Fixed_Identifier_Generator.php`: + +```php title="Fixed_Identifier_Generator.php" +identifier; + } +} +``` + +Bind the fixture before resolving the service under test: + +```php +$identifier = '01ARYZ6S410000000000000000'; + +$this->container->bind( + IdentifierGenerator::class, + new Fixed_Identifier_Generator( $identifier ) +); + +$service = $this->container->get( Job_Creator::class ); + +$this->assertSame( $identifier, $service->create_id() ); +``` + +Use `UlidValidator` when a test only needs to confirm that production generation returns a valid ULID. Avoid asserting an exact value from the system clock and secure entropy. diff --git a/src/Docs/src/content/docs/components/lock.mdx b/src/Docs/src/content/docs/components/lock.mdx new file mode 100644 index 0000000..cb80317 --- /dev/null +++ b/src/Docs/src/content/docs/components/lock.mdx @@ -0,0 +1,342 @@ +--- +title: Lock +description: Coordinate work safely across WordPress requests, workers, and servers with expiring ownership tokens. +sidebar: + order: 3 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +Foundation locks prevent two processes from performing the same protected work at the same time. Every implementation uses the shared `Lock` contract and returns an expiring `LockToken` that proves ownership. + +Locks are useful when processing renewals, synchronizing a remote catalog, rebuilding a shared resource, or running any operation that must not overlap for the same record. + +## Installation + +### Choose where requests coordinate + +Choose an implementation based on which processes must see the same lock: + +| Implementation | Package | Use when | +| --- | --- | --- | +| `InMemoryLock` | `stellarwp/foundation-lock` | Tests or work confined to one PHP process | +| `DatabaseLock` | `stellarwp/foundation-database` | WordPress requests coordinate through the site's database | +| `RedisLock` | `stellarwp/foundation-lock-redis` | Multiple processes or servers coordinate through a dedicated Redis connection | + +:::caution[Do not use InMemoryLock across requests] +`InMemoryLock` cannot coordinate separate PHP requests, workers, or servers. Use the database or Redis implementation for production work that can run concurrently. +::: + +### Install a lock implementation + +Install only the shared contract and in-memory implementation when no persistent coordination is needed: + +```shell +composer require stellarwp/foundation-lock +``` + +For database-backed locks in WordPress: + +```shell +composer require stellarwp/foundation-database +``` + +For Redis-backed locks: + +```shell +composer require stellarwp/foundation-lock-redis +``` + +:::note[The shared lock package is included] +The database and Redis packages install `stellarwp/foundation-lock` automatically. Do not require it separately when using either persistent implementation. +::: + +### Prepare the application container + +The backend examples below assume the application already has one composition root and registers feature providers through `App`. Review these guides before adding a lock provider: + + + + + + +## Configuration + +### Use the WordPress database + +The database implementation is the simplest persistent option when every process can reach the same primary WordPress database. Its guide covers provider wiring, lock-table initialization, and database-specific operating constraints. + + + +### Use a dedicated Redis connection + +Redis is appropriate when requests and workers coordinate across several application servers. Install one supported client in addition to the Redis lock package: + +```shell +composer require "predis/predis:>=3.0 <4.0" +``` + +Alternatively, install and enable the PhpRedis extension. + +Map the connection and key prefix in the root `config.php`: + +```php title="config.php" + [ + 'redis' => [ + 'host' => $_ENV['FOUNDATION_LOCK_REDIS_HOST'] ?? '127.0.0.1', + 'port' => (int) ( $_ENV['FOUNDATION_LOCK_REDIS_PORT'] ?? 6379 ), + 'database' => (int) ( $_ENV['FOUNDATION_LOCK_REDIS_DATABASE'] ?? 1 ), + 'prefix' => $_ENV['FOUNDATION_LOCK_REDIS_PREFIX'] ?? 'your-plugin:lock:', + ], + ], +]; +``` + +Configure Predis and select `RedisLock` in the application's `src/Lock/Provider.php`: + +```php title="Provider.php" +register_connection(); + $this->register_lock(); + } + + private function register_connection(): void { + $this->container->when( Client::class ) + ->needs( '$parameters' ) + ->give( fn (): array => [ + 'host' => (string) $this->config->get( 'lock.redis.host' ), + 'port' => (int) $this->config->get( 'lock.redis.port' ), + 'database' => (int) $this->config->get( 'lock.redis.database' ), + ] ); + + $this->container->singleton( Client::class ); + $this->container->when( PredisConnection::class ) + ->needs( ClientInterface::class ) + ->give( static fn ( C $c ): Client => $c->get( Client::class ) ); + + $this->container->singleton( PredisConnection::class ); + $this->container->singleton( + Connection::class, + static fn ( C $c ): PredisConnection => $c->get( PredisConnection::class ) + ); + } + + private function register_lock(): void { + $this->container->singleton( + Lock::class, + static fn ( C $c ): RedisLock => $c->get( RedisLock::class ) + ); + } +} +``` + +Register the Foundation Redis provider and the application lock provider directly in `src/App.php`, in that order: + +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Providable; +use StellarWP\Foundation\LockRedis\LockRedisProvider; +use YourPlugin\Lock; + +/** @var list> */ +private const array PROVIDERS = [ + LockRedisProvider::class, + Lock\Provider::class, +]; +``` + +:::caution[Keep locks separate from the WordPress object cache] +Use a dedicated Redis connection and key prefix. A separate logical database protects locks from `FLUSHDB` against the object-cache database, but not from `FLUSHALL`, eviction, restart, or failover. Use a separate Redis endpoint when stronger isolation is required. +::: + +Redis Cluster supports only database `0`, so it requires endpoint and key-prefix isolation. Foundation currently supports one writable Redis endpoint; Redis Cluster and Sentinel are not supported. + +## Usage + +### Stop two requests from processing the same resource + +Application services should depend on `Lock`, not a backend class. For example, create `src/Catalog/Catalog_Synchronizer.php` and include the resource identifier in its lock name so unrelated work can proceed concurrently: + +```php title="Catalog_Synchronizer.php" +lock->acquire( + sprintf( 'catalog:%d:sync', $site_id ), + 300 + ); + + if ( $token === null ) { + return false; + } + + try { + $synchronize(); + } catch ( Throwable $failure ) { + try { + $this->lock->release( $token ); + } catch ( Throwable ) { + // Preserve the synchronization failure when cleanup also fails. + } + + throw $failure; + } + + if ( ! $this->lock->release( $token ) ) { + throw new RuntimeException( 'Catalog synchronization lock ownership was lost.' ); + } + + return true; + } +} +``` + +A `false` result means another process owns that site's lock. The caller can skip the duplicate request, retry later, or enqueue it without blocking synchronization for other sites. + +### Understand lock results + +Each operation distinguishes contention or lost ownership from an infrastructure failure: + +| Operation | Success | Contention or lost ownership | +| --- | --- | --- | +| `acquire($name, $ttl)` | Returns a `LockToken` | Returns `null` | +| `release($token)` | Returns `true` | Returns `false` | +| `refresh($token, $ttl)` | Returns a renewed `LockToken` | Returns `null` | +| `isAcquired($name)` | Returns the current observed state | Advisory only; do not use it before acting | + +`isAcquired()` cannot safely replace `acquire()`. Another process can acquire or release the lock immediately after the check. + +:::caution[Do not treat an unavailable backend as lock contention] +`acquire()` returning `null` is an expected result: another process owns the lock. Skip the duplicate work or retry it later. + +`LockUnavailableException` means Foundation could not generate secure ownership or could not determine the backend operation's result. Abort or retry the protected operation. Do not continue without a lock because ownership may be unknown. + +```php +try { + $token = $lock->acquire( 'catalog:42:sync', 300 ); +} catch ( LockUnavailableException $exception ) { + // Coordination failed. Report the failure and retry the whole operation later. + return; +} + +if ( $token === null ) { + // Another process owns the lock. Skip this duplicate attempt. + return; +} + +// Perform the protected work and release the token as shown above. +``` +::: + +### Keep a lease during long work + +Locks are time-bounded leases. Choose a TTL longer than the protected operation, or refresh ownership before the current token expires: + +```php +$refreshed = $lock->refresh( $token, 120 ); + +if ( $refreshed === null ) { + // Ownership expired or was lost. Do not continue protected work. + return; +} + +$token = $refreshed; +``` + +Only `Lock::refresh()` renews the backend lease. The returned token contains the new expiration and must replace the previous token. + +:::caution[Blocking operations cannot heartbeat themselves] +If one remote request may block longer than the remaining lease, choose a conservative TTL before starting it. A lock that expires while work is still running can be acquired by another process. +::: + +:::caution[A lock does not make remote side effects exactly-once] +A lock coordinates cooperating application processes only while its lease remains valid. It cannot determine whether a payment gateway or another remote API completed a request after a timeout, and another process can acquire an expired lock. + +For payments, order creation, email delivery, and similar side effects, also use the remote system's idempotency support. Derive the idempotency key from the stable business operation, such as a renewal order ID. The lock reduces local concurrency; the idempotency key prevents the remote operation from being applied twice. + +```php +$renewal_id = 1042; +$operation_name = sprintf( 'renewal:%d', $renewal_id ); +$token = $lock->acquire( $operation_name, 300 ); + +if ( $token === null ) { + return; +} + +// Every attempt for this renewal sends the same key, including retries after a timeout. +$gateway->charge( + $renewal_id, + [ 'idempotency_key' => $operation_name ] +); + +// Release the token as shown above. +``` +::: + +## Testing + +### Use in-memory locks in tests + +Inject `InMemoryLock` when a test needs real ownership and expiration behavior without a database or Redis service: + +```php +use StellarWP\Foundation\Lock\InMemoryLock; + +$service = new Catalog_Synchronizer( new InMemoryLock() ); +``` + +Because application code depends on the shared `Lock` contract, the production backend can change without changing the service under test. diff --git a/src/Docs/src/content/docs/components/log.mdx b/src/Docs/src/content/docs/components/log.mdx new file mode 100644 index 0000000..2d948f8 --- /dev/null +++ b/src/Docs/src/content/docs/components/log.mdx @@ -0,0 +1,252 @@ +--- +title: Log +description: Add configurable PSR-3 logging to application services with console, PHP error log, stacked, or null channels. +sidebar: + order: 4 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +Foundation Log configures [Monolog](https://github.com/Seldaek/monolog) behind the standard `Psr\Log\LoggerInterface`. Application services depend on the PSR-3 contract, while configuration selects where records are written and which levels are kept. + +## Installation + +Install the split package: + +```shell +composer require stellarwp/foundation-log +``` + +### Prepare the application + +Foundation Log uses the shared application configuration and provider architecture established in these guides: + + + + + + + +## Configuration + +### Choose where records are written + +Set one channel for the application: + +| Channel | Writes to | Use when | +| --- | --- | --- | +| `console` | A configured stream, with colored levels | Local development, CLI processes, or container logs | +| `errorlog` | PHP's `error_log()` | The hosting platform collects the PHP error log | +| `stack` | Both `console` and `errorlog` | The same records must reach the process stream and PHP error log | +| `null` | Nothing | Logging must be intentionally disabled, including in focused tests | + +:::caution[Choose a production channel explicitly] +The example below defaults to `null` so an unconfigured application does not write somewhere unexpected. Set `APP_LOG_CHANNEL` in deployed environments where records must be retained; otherwise failures will not be logged. +::: + +Map the channel, minimum level, and stream in the application's root `config.php`: + +```php title="config.php" + [ + 'channel' => $_ENV['APP_LOG_CHANNEL'] ?? 'null', + 'level' => $_ENV['APP_LOG_LEVEL'] ?? 'info', + 'channels' => [ + 'console' => [ + 'with' => [ + 'stream' => 'php://stdout', + ], + ], + 'stack' => [ + 'with' => [ + 'stream' => 'php://stdout', + ], + ], + ], + ], +]; +``` + +The stream setting is used by `console` and by the console side of `stack`. Common values are `php://stdout` and `php://stderr`. + +### Choose the minimum level + +The configured level keeps records at that severity and above: + +| Level | Typical use | +| --- | --- | +| `debug` | Detailed diagnostics useful during development | +| `info` | Normal application milestones | +| `notice` | Significant but expected events | +| `warning` | Unexpected conditions from which the operation can recover | +| `error` | An operation failed but the application can continue | +| `critical` | A major application capability is unavailable | +| `alert` | Immediate operator action is required | +| `emergency` | The application or site is unusable | + +Use lowercase names in configuration. Foundation also accepts title case and uppercase variants. + +### Register the logging provider + +In `src/App.php`, add `LogProvider` before feature providers that consume `LoggerInterface`: + +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Providable; +use StellarWP\Foundation\Log\LogProvider; +use YourPlugin\Catalog; + +/** @var list> */ +private const array PROVIDERS = [ + LogProvider::class, + Catalog\Provider::class, +]; +``` + +`LogProvider` is an optional default. Applications that need rotating files, a remote log service, custom processors, or different failure behavior can omit it and bind `LoggerInterface` in their own provider. + +### Understand configuration failures + +An unavailable PHP `error_log()` function does not stop the application: + +- The `errorlog` channel falls back to the null handler. +- The `stack` channel keeps the console handler and skips the unavailable error-log handler. + +Invalid configuration is different. An unsupported level fails while `LogProvider` is registered, and an unsupported channel fails when `LoggerInterface` is first resolved. Use one of the documented values rather than silently losing records because of a typo. + +```php +// error_log() is disabled: the application continues without that handler. +$_ENV['APP_LOG_CHANNEL'] = 'errorlog'; + +// Unsupported configuration: fix the value instead of continuing silently. +$_ENV['APP_LOG_CHANNEL'] = 'file'; +``` + +## Usage + +### Inject the PSR-3 logger + +In `src/Catalog/Catalog_Importer.php`, depend on `Psr\Log\LoggerInterface`, not Monolog or a Foundation handler. Include structured context with identifiers and values needed to investigate the event: + +```php title="Catalog_Importer.php" +logger->info( + 'Starting catalog import.', + [ + 'site_id' => $site_id, + 'product_count' => count( $products ), + ] + ); + + foreach ( $products as $product ) { + if ( empty( $product['sku'] ) ) { + $this->logger->warning( + 'Skipping a product without a SKU.', + [ + 'site_id' => $site_id, + 'product_id' => $product['id'] ?? null, + ] + ); + + continue; + } + + // Import the product. + } + } +} +``` + +Context remains machine-readable and keeps operational data out of the message text. Do not include passwords, access tokens, payment details, or other secrets. + +### Record exceptions with their context + +Pass the exception under the conventional `exception` key so handlers and processors can inspect it: + +```php +try { + $this->catalog->synchronize( $site_id ); +} catch ( Throwable $exception ) { + $this->logger->error( + 'Catalog synchronization failed.', + [ + 'site_id' => $site_id, + 'exception' => $exception, + ] + ); + + throw $exception; +} +``` + +Log the failure at the boundary responsible for handling or reporting it. Avoid recording the same exception again at every layer through which it passes. + +## Testing + +### Disable records when logging is irrelevant + +Replace the application logger with the PSR-3 `NullLogger` when a focused test does not assert logging behavior: + +```php +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; + +$this->container->bind( LoggerInterface::class, NullLogger::class ); +``` + +### Assert important records + +Monolog's `TestHandler` captures records without writing them to an external destination: + +```php +use Monolog\Handler\TestHandler; +use Monolog\Logger; +use Psr\Log\LoggerInterface; + +$handler = new TestHandler(); +$logger = new Logger( 'test', [ $handler ] ); + +$this->container->bind( LoggerInterface::class, $logger ); + +$service = $this->container->get( Catalog_Importer::class ); +$service->import( 42, [ [ 'id' => 10 ] ] ); + +$this->assertTrue( $handler->hasWarning( [ + 'message' => 'Skipping a product without a SKU.', + 'context' => [ + 'site_id' => 42, + 'product_id' => 10, + ], +] ) ); +``` + +Assert logs only when they are part of the feature's observable operational contract. Otherwise, test the feature's result and use `NullLogger`. diff --git a/src/Docs/src/content/docs/components/pipeline.mdx b/src/Docs/src/content/docs/components/pipeline.mdx new file mode 100644 index 0000000..73ed681 --- /dev/null +++ b/src/Docs/src/content/docs/components/pipeline.mdx @@ -0,0 +1,325 @@ +--- +title: Pipeline +description: Pass a value through an ordered sequence of container-resolved transformations and checks. +sidebar: + order: 6 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +Foundation Pipeline passes a value through an ordered chain of pipes. Each pipe can transform the value, perform a check, stop execution, or call the next pipe. The implementation is based on Laravel's Pipeline pattern and uses the Foundation container to resolve class-based pipes. + +Pipelines are useful when one operation has several independent steps whose order should remain visible, such as normalizing input, validating business rules, enriching data, and persisting the final result. + +## Installation + +Install the split package: + +```shell +composer require stellarwp/foundation-pipeline +``` + +### Prepare the application + +Pipeline has no service provider or configuration file. It uses the shared container when class names are supplied as pipes: + + + + + + + +## Configuration + +### Configure a pipeline in a provider + +Define the ordered pipe list in the feature provider, then use a contextual binding to select that configured pipeline for its consumer. This keeps workflow composition out of the class that runs it. + +In `src/Catalog/Provider.php`: + +```php title="Provider.php" +register_product_import_pipeline(); + } + + private function register_product_import_pipeline(): void { + $this->container->bind( + self::PRODUCT_IMPORT_PIPELINE, + static fn ( C $c ): Pipeline => $c->get( Pipeline::class )->through( [ + Normalize_Product::class, + Require_Product_Sku::class, + ] ) + ); + + $this->container->when( Product_Importer::class ) + ->needs( Pipeline::class ) + ->give( + static fn ( C $c ): Pipeline => $c->get( self::PRODUCT_IMPORT_PIPELINE ) + ); + } +} +``` + +In `src/Catalog/Product_Importer.php`, the consumer uses the pipeline it receives without knowing which pipes compose it: + +```php title="Product_Importer.php" + $product + * + * @throws InvalidArgumentException When the product has no SKU. + */ + public function import( array $product ): Product { + /** @var Product $imported */ + $imported = $this->pipeline + ->send( $product ) + ->then( + fn ( array $normalized ): Product => $this->products->save( $normalized ) + ); + + return $imported; + } +} +``` + +:::caution[Use a prototype binding] +Use `bind()` for configured pipelines. `send()`, `through()`, `pipe()`, and `via()` change the pipeline instance, so a singleton can leak mutable state between operations in a long-running process. +::: + +Register each distinct workflow under its own container identifier and contextually give consumers the one they need. Class-name pipes are resolved through the container, so their own dependencies remain injectable. + +## Usage + +### Create a transforming pipe + +Create `src/Catalog/Normalize_Product.php` for the first pipe. A pipe receives the current value and a `$next` closure; pass the transformed value to `$next` to continue: + +```php title="Normalize_Product.php" + $product + * + * @return mixed + */ + public function handle( array $product, Closure $next ): mixed { + $product['sku'] = strtoupper( trim( (string) ( $product['sku'] ?? '' ) ) ); + $product['name'] = trim( (string) ( $product['name'] ?? '' ) ); + + return $next( $product ); + } +} +``` + +The first configured pipe runs first. The destination passed to `then()` runs only after every pipe calls `$next`. + +### Reject invalid input + +Create `src/Catalog/Require_Product_Sku.php` for the validation pipe. Throw when the operation cannot continue; Pipeline rethrows exceptions from pipes and the destination unchanged: + +```php title="Require_Product_Sku.php" + $product + * + * @throws InvalidArgumentException When the product has no SKU. + * + * @return mixed + */ + public function handle( array $product, Closure $next ): mixed { + if ( $product['sku'] === '' ) { + throw new InvalidArgumentException( 'A product SKU is required.' ); + } + + return $next( $product ); + } +} +``` + +Catch the exception at the application boundary that can report, retry, or convert the failure. Avoid swallowing it inside the pipeline unless stopping is an expected result. + +### Stop without running later pipes + +A pipe short-circuits the pipeline by returning a result without calling `$next`: + +```php +public function handle( array $product, Closure $next ): mixed { + if ( ( $product['status'] ?? '' ) === 'ignored' ) { + return $product; + } + + return $next( $product ); +} +``` + +In this example, later pipes and the final destination do not run. Use short-circuiting only when the returned type is valid for the entire pipeline; otherwise callers receive an unexpected result type. + +### Choose a pipe form + +The provider's `through()` call accepts several pipe forms: + +| Pipe | Behavior | +| --- | --- | +| Class name | Resolved through the container; `handle()` is called when present, otherwise the object must be invokable | +| Object | Used directly; `handle()` is called when present, otherwise the object must be invokable | +| Callable | Called directly with the current value and `$next` | +| `ClassName:param1,param2` | Resolved through the container and given the extra string parameters after `$next` | + +Prefer class-name pipes for reusable application behavior because their constructor dependencies remain injectable. Closures are useful for a small operation local to one configured pipeline: + +```php +static fn ( C $c ): Pipeline => $c->get( Pipeline::class ) + ->through( + Normalize_Product::class, + static fn ( array $value, Closure $next ): mixed => $next( [ + ...$value, + 'source' => 'remote', + ] ) + ); +``` + +The consumer uses `thenReturn()` when the fully processed value is the result. It uses `then()` when the destination performs the final operation, such as saving the normalized product. + +### Pass literal parameters to a pipe + +Append comma-separated string parameters after the class name in the provider's pipe list: + +```php +$pipeline->through( + Replace_Product_Status::class . ':draft,pending' +); +``` + +The matching pipe receives them after the value and `$next`: + +```php +public function handle( + array $product, + Closure $next, + string $from, + string $to +): mixed { + if ( ( $product['status'] ?? '' ) === $from ) { + $product['status'] = $to; + } + + return $next( $product ); +} +``` + +Parameters from the pipe string are always strings. Prefer constructor injection and provider configuration for service dependencies or structured configuration. + +### Use a different pipe method + +Pipes use `handle()` by default. Configure `via()` when every object pipe in that pipeline exposes another method: + +```php +static fn ( C $c ): Pipeline => $c->get( Pipeline::class ) + ->via( 'process' ) + ->through( [ + Product_Normalizer::class, + Product_Validator::class, + ] ); +``` + +Both classes in this example must expose `process( $value, Closure $next )`. Use `through()` to replace the configured pipe list and `pipe()` to append additional pipes. + +## Testing + +### Test each pipe in isolation + +Call the pipe with an identity closure so the test observes the value passed to the next stage: + +```php +$pipe = new Normalize_Product(); + +$result = $pipe->handle( + [ + 'sku' => ' abc-123 ', + 'name' => ' Example product ', + ], + static fn ( array $product ): array => $product +); + +$this->assertSame( 'ABC-123', $result['sku'] ); +$this->assertSame( 'Example product', $result['name'] ); +``` + +### Test the configured order once + +Use the real container for one focused test that resolves the consumer and proves the provider-configured pipeline runs in the intended order: + +```php +$product = $this->container->get( Product_Importer::class )->import( [ + 'sku' => ' abc-123 ', + 'name' => ' Example product ', + ] ); + +$this->assertSame( 'ABC-123', $product->sku() ); +``` + +Keep most tests on individual pipes. The pipeline package already owns the generic chaining behavior; application tests need to prove only their transformations, short circuits, and configured order. diff --git a/src/Docs/src/content/docs/components/shutdown.mdx b/src/Docs/src/content/docs/components/shutdown.mdx new file mode 100644 index 0000000..16253a8 --- /dev/null +++ b/src/Docs/src/content/docs/components/shutdown.mdx @@ -0,0 +1,235 @@ +--- +title: Shutdown +description: Run bounded application termination work once in deterministic priority order. +sidebar: + order: 7 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +Some work must happen before a PHP request ends, but does not need to delay the response sent to the client. For example, an application may need to flush buffered logs or telemetry to a third-party service, close request-scoped resources, or publish diagnostics collected during the request. Running that work before returning the response makes the website or API feel slower even though the result does not affect what the client receives. + +Foundation Shutdown lets features contribute these end-of-request tasks from their providers. In WordPress, it attempts to finish the HTTP response before running the tasks from the `shutdown` action. Each task runs once in priority order, and one failed task does not prevent the remaining tasks from running. + +Use shutdown tasks for bounded, best-effort work that can finish within the current PHP process. They are not asynchronous jobs: the PHP worker remains occupied until every task finishes. + +:::note[Use a queue for durable work] +Use a durable queue when work is long-running, must be retried, or cannot safely be lost if the process exits. +::: + +## Installation + +Install the runtime package: + +```shell +composer require stellarwp/foundation-shutdown +``` + +### Prepare the application + +Shutdown tasks use the application's existing container and ordered provider graph: + + + + + + + +## Configuration + +### Configure the cache write + +In the root `config.php`, choose the WordPress transient key and lifetime used for the cached snapshot: + +```php title="config.php" + [ + 'key' => $_ENV['PRODUCT_CACHE_KEY'] ?? 'your_plugin_products', + 'ttl' => (int) ( $_ENV['PRODUCT_CACHE_TTL'] ?? 300 ), + ], +]; +``` + +### Create a cache-write task + +Create `src/Product_Cache/Product_Cache_Writer.php`. This example assumes `Product_Cache_Buffer` collects an in-memory snapshot while the application handles the request. The task writes that prepared snapshot to a WordPress transient only when it changed: + +```php title="Product_Cache_Writer.php" +cache_key === '' ) { + throw new InvalidArgumentException( 'The product cache key cannot be empty.' ); + } + + if ( $this->ttl < 1 ) { + throw new InvalidArgumentException( 'The product cache TTL must be greater than zero.' ); + } + } + + /** + * @action shutdown + */ + public function terminate(): void { + if ( ! $this->buffer->has_changes() ) { + return; + } + + set_transient( + $this->cache_key, + $this->buffer->snapshot(), + $this->ttl + ); + } +} +``` + +The buffer is responsible for collecting cacheable state during the request. The shutdown task only performs the final bounded write. The PHP worker remains occupied until `terminate()` returns, even when the response has already been sent to the client. + +### Contribute the task from its feature provider + +In `src/Product_Cache/Provider.php`, supply the task's scalar configuration before adding it lazily to `ShutdownProvider::TASKS`: + +```php title="Provider.php" +register_cache_writer(); + } + + private function register_cache_writer(): void { + $this->container->when( Product_Cache_Writer::class ) + ->needs( '$cache_key' ) + ->give( (string) $this->config->get( 'product_cache.key', 'your_plugin_products' ) ); + + $this->container->when( Product_Cache_Writer::class ) + ->needs( '$ttl' ) + ->give( (int) $this->config->get( 'product_cache.ttl', 300 ) ); + + $this->container->mergeArrayVar( + ShutdownProvider::TASKS, + static fn ( C $c ): array => [ + new ShutdownTask( + $c->get( Product_Cache_Writer::class ), + self::WRITE_PRIORITY + ), + ] + ); + } +} +``` + +The contextual bindings target the task's `$cache_key` and `$ttl` constructor arguments. The container autowires `Product_Cache_Buffer`, applies those configured scalar values when the task collection is resolved, and then constructs the task. + +`ShutdownTask` is an immutable value object pairing one `Terminable` service with its priority. Lower values run first. Tasks with the same priority retain provider contribution order. + +### Register the providers + +In `src/App.php`, register `ShutdownProvider` before the feature provider that contributes the cache task: + +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Providable; +use StellarWP\Foundation\Shutdown\ShutdownProvider; +use YourPlugin\Product_Cache; + +/** @var list> */ +private const array PROVIDERS = [ + ShutdownProvider::class, + Product_Cache\Provider::class, +]; +``` + +The shutdown provider registers the shared task collection, the decorated runner, and one callback on WordPress's `shutdown` action at `PHP_INT_MAX`. It does not register that automatic callback while WordPress is installing or the plugin is being uninstalled. Registration alone does not construct or run contributed tasks. + +If the application uses `foundation-log`, register `LogProvider` with the other infrastructure providers before the feature providers. The shutdown runner receives the configured `LoggerInterface` automatically. + +Register every contributing provider before resolving the shutdown runner. The normal application bootstrap does this automatically because the runner is resolved only when the WordPress action fires. + +## Usage + +### Run tasks at WordPress shutdown + +No application hook is required after `ShutdownProvider` is registered. At WordPress shutdown, Foundation: + +1. Resolves the complete contributed task collection. +2. Attempts `fastcgi_finish_request()` and then `litespeed_finish_request()` when available, stopping after one succeeds. +3. Runs tasks from the lowest priority to the highest. +4. Runs equal-priority tasks in contribution order. +5. Ignores repeated or recursive calls to the same runner instance. + +Response finishing is best effort. A missing function, a `false` result, or a thrown exception does not prevent termination tasks from running. + +### Invoke the runner from another lifecycle + +An application with another explicit termination boundary can resolve the configured contract directly: + +```php +use StellarWP\Foundation\Shutdown\Contracts\ShutdownRunner; + +$container->get( ShutdownRunner::class )->terminate(); +``` + +Each runner instance executes only once. Calling it before WordPress shutdown means the later `shutdown` callback is a no-op for that instance. + +### Handle task failures + +The runner catches every `Throwable` from a task and continues with the remaining tasks. When a PSR-3 logger is available, it records task execution at `debug` and task failures at `error`, including the task class, priority, and exception. Logger failures are also isolated. + +:::caution[Shutdown work is best effort] +Task exceptions are not rethrown, and PHP shutdown provides no retry or durability guarantee. Do not use this component for payments, required data persistence, customer notifications, or work that must eventually complete. Persist that work before the request ends and process it through a durable queue. +::: + +:::caution[Finishing the response is not asynchronous execution] +`fastcgi_finish_request()` or `litespeed_finish_request()` may let the client receive its response sooner, but the PHP worker remains busy. Keep shutdown tasks short and move slow or unbounded operations to a queue. +::: + +## Testing + +Test each `Terminable` service directly through its observable behavior. Add one provider integration test when the contribution itself matters: register `ShutdownProvider` and the feature provider, resolve the `ShutdownRunner` contract, call `terminate()`, and assert the task's effect. + +The package already tests priority ordering, equal-priority stability, once-only execution, recursive invocation, response-finishing fallbacks, failure isolation, and optional logging. Application tests do not need to duplicate those generic guarantees. diff --git a/src/Docs/src/content/docs/components/view.mdx b/src/Docs/src/content/docs/components/view.mdx new file mode 100644 index 0000000..941b66c --- /dev/null +++ b/src/Docs/src/content/docs/components/view.mdx @@ -0,0 +1,285 @@ +--- +title: View +description: Render trusted PHP templates to strings from configured or runtime-selected directories. +sidebar: + order: 9 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +Foundation View provides a small contract for rendering named views and a default `PhpView` implementation that renders trusted PHP templates from an explicit directory. It keeps markup in view files while application services remain responsible for selecting the view and preparing its data. + +`PhpView` uses ordinary PHP templates without introducing custom template syntax. It captures their output and returns it as a string instead of echoing it automatically. A configured renderer can also create an immutable renderer for another trusted directory at runtime. + +## Installation + +Install the split package: + +```shell +composer require stellarwp/foundation-view +``` + +### Prepare the application + +Foundation View uses the shared application configuration and provider architecture established in these guides: + + + + + + + +## Configuration + +### Choose the default view directory + +Create a `views/` directory at the application root. In the root `config.php`, provide its absolute path: + +```php title="config.php" + [ + 'directory' => __DIR__ . '/views', + ], +]; +``` + +`view.directory` is required and must identify an existing, readable directory. Foundation resolves it to its canonical path before rendering. + +### Register the view provider + +In `src/App.php`, add `ViewProvider` before feature providers that consume the `View` contract: + +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Providable; +use StellarWP\Foundation\View\ViewProvider; +use YourPlugin\Admin; + +/** @var list> */ +private const array PROVIDERS = [ + ViewProvider::class, + Admin\Provider::class, +]; +``` + +`ViewProvider` binds one shared `PhpView` instance to `StellarWP\Foundation\View\Contracts\View` and `StellarWP\Foundation\View\Contracts\DirectoryAwareView`. + +## Usage + +### Create a view before rendering it + +View names are relative to the configured directory and omit the `.php` extension. For the name `admin/product-summary`, first create `views/admin/product-summary.php`: + +```php title="product-summary.php" + +
+

+

+
+``` + +:::note[Document every view variable] +Add a typed `@var` annotation, including a short description, for every value the view expects. These annotations define the template's input contract, allow PHPStan to analyze the file without undefined-variable errors, and provide type-aware completion in supported IDEs. +::: + +:::caution[Escape output in the view] +Foundation passes data into trusted PHP files but does not escape it automatically. Escape each value for its HTML, attribute, URL, or JavaScript context when the view outputs it. Do not render user-uploaded PHP templates. +::: + +### Render the view from a service + +In `src/Admin/Product_Summary_Notice.php`, inject the `View` contract and return or echo the rendered string at the application boundary: + +```php title="Product_Summary_Notice.php" +products->count(); + + echo $this->view->render( + 'admin/product-summary', + [ + 'title' => __( 'Product catalog', 'your-plugin' ), + 'summary' => sprintf( + /* translators: %d: number of products. */ + _n( '%d product is available.', '%d products are available.', $count, 'your-plugin' ), + $count + ), + ] + ); + } +} +``` + +Register the WordPress callback from `src/Admin/Provider.php`: + +```php title="Provider.php" +container->callback( Product_Summary_Notice::class, 'display' ) + ); + } +} +``` + +### Select another directory at runtime + +Inject `DirectoryAwareView` instead of the base `View` contract when a service must select another trusted template root, such as a theme override directory: + +```php title="Receipt_Renderer.php" +view->withDirectory( $trusted_template_directory ); + + return $renderer->render( + 'email/receipt', + [ 'receipt' => $receipt ] + ); + } +} +``` + +`withDirectory()` returns a new renderer. It does not mutate the shared renderer or affect other services using the configured directory. + +:::caution[Do not accept a directory from request input] +Runtime directory selection is for trusted application paths. Never pass a URL parameter, form value, REST field, or other untrusted input to `withDirectory()`. +::: + +### Supply another renderer + +The base `View` contract requires only named rendering. A renderer that does not use PHP files or directories can implement it without supporting `withDirectory()`: + +```php title="Json_View.php" + $name, + 'data' => $data, + ], + JSON_THROW_ON_ERROR + ); + } +} +``` + +Bind the replacement from the application's feature provider instead of registering `ViewProvider`: + +```php title="Provider.php" +use StellarWP\Foundation\View\Contracts\View; + +public function register(): void { + $this->container->singleton( View::class, Json_View::class ); +} +``` + +Use a separate capability contract when a custom renderer supports optional behavior such as runtime directory selection. Application services that only call `render()` should continue depending on `View`. + +### Handle missing views + +The renderer throws `ViewNotFoundException` when a view is missing, unreadable, or resolves outside the selected directory. Empty names, absolute paths, null bytes, and parent traversal such as `../private` are rejected with `InvalidArgumentException`. + +Exceptions thrown by the view itself are propagated after Foundation removes any removable buffers opened while rendering. A view may use balanced buffers of its own, but it must not clean, flush, close, or replace Foundation's rendering buffer. Invalid buffer state is rejected instead of returning incomplete output. Let application-level error handling record or present those failures rather than returning a partial template. + +:::danger[Do not create non-removable output buffers] +A PHP template must not start a buffer without `PHP_OUTPUT_HANDLER_REMOVABLE`. PHP cannot close such a buffer before the process ends, so no in-process PHP renderer can restore the request's output-buffer stack afterward. Treat view files as trusted application code and keep any buffers they open balanced and removable. +::: + +## Testing + +Place small PHP view fixtures under the test data directory. For example, create `tests/_data/views/message.php`: + +```php title="message.php" +

+``` + +Render the fixture with the concrete class: + +```php +use StellarWP\Foundation\View\PhpView; + +$view = new PhpView( codecept_data_dir( 'views' ) ); + +$this->assertSame( + '

Hello, Foundation

', + $view->render( + 'message', + [ 'message' => 'Hello, Foundation' ] + ) +); +``` + +Test feature services through the `View` contract when the rendered markup is part of their observable behavior. Use a temporary directory under `tests/_data/temp` for path-containment or runtime-directory tests that must create files. diff --git a/src/Docs/src/content/docs/components/wp-cli.mdx b/src/Docs/src/content/docs/components/wp-cli.mdx new file mode 100644 index 0000000..ea98dd3 --- /dev/null +++ b/src/Docs/src/content/docs/components/wp-cli.mdx @@ -0,0 +1,367 @@ +--- +title: WP-CLI +description: Build container-aware WP-CLI commands and register them from feature providers. +sidebar: + order: 8 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +Foundation WP-CLI provides a container-aware command base class and a shared provider for registering commands during WP-CLI bootstrap. Application services remain injectable, command prefixes remain configurable, and feature providers can contribute commands without loading WP-CLI classes during normal WordPress requests. + +## Installation + +### Install the runtime package + +Install WP-CLI support as a production dependency when the plugin ships commands: + +```shell +composer require stellarwp/foundation-wpcli +``` + +WP-CLI supplies the `WP_CLI` and `WP_CLI_Command` runtime classes. Applications running commands through WP-CLI do not normally need to install `wp-cli/wp-cli` separately. + +:::caution[Install WP-CLI support without `--dev`] +If the plugin ships WP-CLI commands, `stellarwp/foundation-wpcli` must be in Composer's `require` section. A production installation using `--no-dev` omits packages from `require-dev`, leaving the plugin's command classes without their Foundation `Command` base class. +::: + +### Install the generator separately + +Install the developer CLI only when the team wants to generate command classes: + +```shell +composer require --dev stellarwp/foundation-cli +``` + +The generator is development tooling and does not need to ship in a standalone plugin archive. + +### Prepare the application + +WP-CLI commands use the same container, configuration, and provider graph as the rest of the application: + + + + + + + +## Configuration + +### Choose the command prefix + +In the root `config.php`, configure a stable application prefix for a distributable plugin: + +```php title="config.php" + [ + 'prefix' => 'your-plugin', + ], +]; +``` + +Commands will be registered beneath `wp your-plugin`. Complete WordPress applications that own the full installation can keep the zero-configuration `nx` default. + +Set `wpcli.command_prefix` in the same root `config.php` only when WP-CLI should intentionally use a different prefix: + +```php title="config.php" +return [ + 'foundation' => [ + 'prefix' => 'your-plugin', + ], + 'wpcli' => [ + 'command_prefix' => 'your-plugin-tools', + ], +]; +``` + +### Register the WP-CLI provider + +In `src/App.php`, register `WPCliProvider` before feature providers that contribute commands: + +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Providable; +use StellarWP\Foundation\WPCli; +use YourPlugin\Catalog; + +/** @var list> */ +private const array PROVIDERS = [ + WPCli\WPCliProvider::class, + Catalog\Provider::class, +]; +``` + +`WPCliProvider` listens to `cli_init` and resolves the command collection only when WP-CLI is active. Feature providers should contribute commands to that collection instead of registering their own `cli_init` hooks. + +### Contribute a command from its feature provider + +`WPCliProvider` creates the shared `CommandPrefix` from configuration. Feature providers only add their commands to the shared collection. + +In `src/Catalog/Provider.php`: + +```php title="Provider.php" +register_cli_commands(); + } + + private function register_cli_commands(): void { + $this->container->mergeArrayVar( + WPCliProvider::COMMANDS, + static fn ( C $c ): array => [ + $c->get( Sync_Catalog_Command::class ), + ] + ); + } +} +``` + +:::danger[Do not bind command classes separately] +Do not call `bind()` or `singleton()` for `Sync_Catalog_Command`. DI52 may autoload its `WP_CLI_Command` parent during normal WordPress bootstrap, before WP-CLI exists. The lazy `mergeArrayVar()` contribution resolves the command during `cli_init` instead. +::: + +Add more commands to the same returned array or contribute them from other feature providers. `WPCliProvider` validates the complete collection before registering any command. + +## Usage + +### Generate a command + +Generate the initial class from the project root: + +```shell +vendor/bin/foundation make:wpcli-command Sync_Catalog \ + --namespace="YourPlugin\\Catalog\\Cli" \ + --subcommand="catalog:sync" \ + --description="Synchronize the product catalog." +``` + +The generator uses Composer's PSR-4 mapping to write `src/Catalog/Cli/Sync_Catalog_Command.php`. It creates a Snake_Case class with examples of a positional argument, associative option, and flag. + +Projects using Strauss receive the configured namespace prefix on generated Foundation imports. With `update_call_sites=false`, handwritten provider imports may also need the project's Strauss prefix. + +Project-specific command stubs can override the default at `foundation/stubs/wpcli/command.stub`. + +### Implement the command + +Keep the command focused on input, output, and selecting the application operation. Inject the service that owns the business behavior rather than resolving it from the container. + +In `src/Catalog/Cli/Sync_Catalog_Command.php`: + +```php title="Sync_Catalog_Command.php" + $args + * @param array $assocArgs + * + * @throws \WP_CLI\ExitException When command input is invalid. + */ + public function runCommand( array $args = [], array $assocArgs = [] ): int { + $source = (string) ( $args[0] ?? '' ); + $batchSize = absint( get_flag_value( + $assocArgs, + self::OPTION_BATCH_SIZE, + self::DEFAULT_BATCH_SIZE + ) ); + $dryRun = (bool) get_flag_value( $assocArgs, self::FLAG_DRY_RUN, false ); + + if ( $batchSize < 1 ) { + WP_CLI::error( __( 'The batch size must be greater than zero.', 'your-plugin' ) ); + } + + $count = $this->synchronizer->sync( $source, $batchSize, $dryRun ); + + if ( $dryRun ) { + WP_CLI::success( sprintf( + /* translators: 1: Product count, 2: Catalog source. */ + __( 'Dry run found %1$d products to synchronize from %2$s.', 'your-plugin' ), + $count, + $source + ) ); + + return self::SUCCESS; + } + + WP_CLI::success( sprintf( + /* translators: 1: Product count, 2: Catalog source. */ + __( 'Synchronized %1$d products from %2$s.', 'your-plugin' ), + $count, + $source + ) ); + + return self::SUCCESS; + } + + protected function subcommand(): string { + return 'catalog:sync'; + } + + protected function description(): string { + return __( 'Synchronize the product catalog.', 'your-plugin' ); + } + + protected function arguments(): array { + return [ + [ + 'type' => self::POSITIONAL, + 'name' => self::ARG_SOURCE, + 'description' => __( 'The catalog source to synchronize.', 'your-plugin' ), + 'optional' => false, + ], + [ + 'type' => self::ASSOCIATIVE, + 'name' => self::OPTION_BATCH_SIZE, + 'description' => __( 'The number of products processed per batch.', 'your-plugin' ), + 'optional' => true, + 'default' => self::DEFAULT_BATCH_SIZE, + ], + [ + 'type' => self::FLAG, + 'name' => self::FLAG_DRY_RUN, + 'description' => __( 'Preview the synchronization without writing changes.', 'your-plugin' ), + 'optional' => true, + ], + ]; + } +} +``` + +The three synopsis types map to WP-CLI input as follows: + +| Type | Declaration | Invocation | +| --- | --- | --- | +| Positional | `source` | `staging` | +| Associative | `batch-size` | `--batch-size=50` | +| Flag | `dry-run` | `--dry-run` | + +### Run the command + +With the `your-plugin` application prefix, run: + +```shell +wp your-plugin catalog:sync staging --batch-size=50 --dry-run +``` + +Inspect the generated synopsis and description with: + +```shell +wp help your-plugin catalog:sync +``` + +:::note[Choose the correct failure signal] +Use `WP_CLI::error()` for invalid input or an operation that cannot continue; it stops the command with a failing exit code. Use `WP_CLI::warning()` when execution can continue. Returning any nonzero status from `runCommand()` also causes the Foundation command wrapper to halt with that status. +::: + +## Testing + +### Test business behavior outside the command + +Keep most tests on `Catalog_Synchronizer` and its collaborators. The command should contain only input normalization, application service invocation, and WP-CLI output behavior. + +### Execute the registered command + +Use the Codeception `wpcli` suite for one end-to-end test that proves the provider contribution, command prefix, arguments, output, and exit code work together. + +In `tests/wpcli/Catalog/SyncCatalogCest.php`: + +```php title="SyncCatalogCest.php" +cli( [ + 'your-plugin', + 'catalog:sync', + 'staging', + '--batch-size=50', + '--dry-run', + ] ); + + $I->seeResultCodeIs( 0 ); + $I->seeInShellOutput( 'Dry run found' ); + } + + public function test_it_rejects_an_invalid_batch_size( WPCLITester $I ): void { + $I->cli( [ + 'your-plugin', + 'catalog:sync', + 'staging', + '--batch-size=0', + ] ); + + $I->seeResultCodeIs( 1 ); + Assert::assertStringContainsString( + 'The batch size must be greater than zero.', + $I->grabLastShellErrorOutput() + ); + } +} +``` + +Run the suite through SLIC: + +```shell +slic run wpcli +``` + +One real command test is more valuable than duplicating the Foundation command wrapper's generic registration tests throughout the application. diff --git a/src/Docs/src/content/docs/index.mdx b/src/Docs/src/content/docs/index.mdx new file mode 100644 index 0000000..b12448d --- /dev/null +++ b/src/Docs/src/content/docs/index.mdx @@ -0,0 +1,40 @@ +--- +title: Foundation +description: Shared PHP infrastructure for Nexcess libraries and WordPress plugins. +template: splash +hero: + title: Foundation + tagline: Shared PHP infrastructure for Nexcess libraries and WordPress plugins. + actions: + - text: Start with Foundation + link: /start/what-is-foundation/ + icon: right-arrow + variant: primary + - text: View on GitHub + link: https://github.com/stellarwp/foundation + icon: external + variant: minimal +--- + +import { Card, CardGrid } from '@astrojs/starlight/components'; + +## Start with the application foundation + + + + Use the aggregate package for convenience or select individual components for a lean production archive. + [Choose an installation approach](/start/install-foundation/) + + + Build the shared container and configuration that Foundation providers use throughout an application. + [Configure the container](/start/configure-the-container/) + + + Keep bindings, hooks, and feature configuration inside focused service providers. + [Register service providers](/start/register-service-providers/) + + + Set a stable application prefix so multiple Foundation consumers do not share commands or database resources. + [Scope Foundation resources](/start/scope-foundation/) + + diff --git a/src/Docs/src/content/docs/start/bootstrap-wordpress-plugin.md b/src/Docs/src/content/docs/start/bootstrap-wordpress-plugin.md new file mode 100644 index 0000000..de05bd4 --- /dev/null +++ b/src/Docs/src/content/docs/start/bootstrap-wordpress-plugin.md @@ -0,0 +1,182 @@ +--- +title: Bootstrap a WordPress Plugin +description: Use an application composition root to configure Foundation and register plugin features in one place. +sidebar: + order: 4 +--- + +A WordPress plugin needs one predictable place to construct its container, load configuration, and register service providers. Keep that work in a small application composition root so feature classes receive fully configured dependencies. + +## Create the application configuration + +The root `config.php` maps environment values into the structure providers consume: + +```php title="config.php" + [ + 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? 'your-plugin', + ], +]; +``` + +## Compose the application in App + +Create `src/App.php`. The application object binds shared values before registering providers, and its provider list makes startup order visible from one file: + +```php title="App.php" +> */ + private const array PROVIDERS = []; + + private static self $instance; + + private function __construct( + private readonly string $plugin_file, + private readonly Container $container, + private readonly Dot $config + ) { + $this->configure_container(); + $this->register_providers(); + } + + public static function instance( + string $plugin_file, + Container $container, + Dot $config + ): self { + if ( ! isset( self::$instance ) ) { + self::$instance = new self( $plugin_file, $container, $config ); + } + + return self::$instance; + } + + public function container(): Container { + return $this->container; + } + + private function configure_container(): void { + $this->container->bind( Container::class, $this->container ); + $this->container->singleton( Dot::class, $this->config ); + $this->container->singleton( self::PLUGIN_FILE, $this->plugin_file ); + $this->container->singleton( self::PLUGIN_DIR, plugin_dir_path( $this->plugin_file ) ); + } + + private function register_providers(): void { + foreach ( self::PROVIDERS as $provider ) { + $this->container->register( $provider ); + } + } +} +``` + +Add infrastructure providers and top-level feature providers directly to `PROVIDERS` as application features are introduced. Keep cross-feature dependencies and their registration order visible in this composition root. + +A large feature may expose one composition provider that registers its own internal providers. That provider should do only that: it must not also register service definitions, configuration, or hooks. + +## Create the application helper + +Create `src/functions.php`. The helper supplies the application dependencies on first use, and `App::instance()` returns the same application for the remainder of the request: + +```php title="functions.php" +container()` when WordPress invokes a callback that cannot receive constructor dependencies, such as activation and deactivation hooks. Application services should continue to use constructor injection. + +## Autoload the application with Composer + +In the root `composer.json`, map the plugin namespace to `src/` and autoload the application helper: + +```json title="composer.json" +{ + "autoload": { + "psr-4": { + "YourPlugin\\\\": "src/" + }, + "files": [ + "src/functions.php" + ] + }, + "require": { + "php": ">=8.3", + "stellarwp/foundation-container": "^2.0" + } +} +``` + +Regenerate Composer's autoloader after changing the mapping: + +```shell +composer dump-autoload +``` + +## Start the application from the plugin entrypoint + +The root `your-plugin.php` can now load Composer and defer application startup to an appropriate WordPress hook: + +```php title="your-plugin.php" +bind( Container::class, $container ); +$container->singleton( Dot::class, $config ); +``` + +Binding the Foundation `Container` contract allows application services to request the shared adapter. The `Dot` binding makes the application's configuration available to every Foundation provider. + +## Map environment values in config.php + +Keep environment access at the configuration boundary rather than reading `$_ENV` throughout application services. Map values in the root `config.php`: + +```php title="config.php" + [ + 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? 'your-plugin', + ], + 'log' => [ + 'channel' => $_ENV['APP_LOG_CHANNEL'] ?? 'null', + 'level' => $_ENV['APP_LOG_LEVEL'] ?? 'debug', + ], +]; +``` + +Providers read nested values through their inherited `$this->config` property: + +```php +$channel = $this->config->get( 'log.channel' ); +``` + +Pass resolved configuration into service constructors through container bindings. Application services should not read the environment or the `Dot` configuration object directly. + +## Load an optional .env file + +The container package includes `vlucas/phpdotenv`. A non-WordPress application can load a local environment file before requiring `config.php`: + +```php +use Dotenv\Dotenv; + +if ( is_file( __DIR__ . '/.env' ) ) { + Dotenv::createImmutable( __DIR__ )->load(); +} +``` + +WordPress applications can map deployment values into `$_ENV` before the plugin constructs its container. Keep local environment files out of production archives. + +## Continue + +[Bootstrap a WordPress plugin](/start/bootstrap-wordpress-plugin/) with one application object that owns the container, configuration, and provider order. diff --git a/src/Docs/src/content/docs/start/install-foundation.md b/src/Docs/src/content/docs/start/install-foundation.md new file mode 100644 index 0000000..8596203 --- /dev/null +++ b/src/Docs/src/content/docs/start/install-foundation.md @@ -0,0 +1,69 @@ +--- +title: Install Foundation +description: Install the aggregate Foundation package or select individual runtime components. +sidebar: + order: 2 +--- + +Foundation requires PHP 8.3 or newer. Composer resolves the dependencies required by the aggregate package or selected components. + +:::caution[Building a production WordPress plugin?] +Do not install `stellarwp/foundation` only to get the Foundation CLI. The aggregate package includes the developer CLI as a normal dependency, so `composer install --no-dev` will not remove it. + +Require only the split runtime packages the plugin ships, then install `stellarwp/foundation-cli` separately with `--dev`. +::: + +## Install runtime components + +Install the packages the application uses in production: + +```shell +composer require \ + stellarwp/foundation-container \ + stellarwp/foundation-log \ + stellarwp/foundation-lock +``` + +Add other components as the application needs them rather than installing integrations speculatively. + +## Install developer tooling separately + +The CLI package generates project code and normally does not belong in a production WordPress plugin archive: + +```shell +composer require --dev stellarwp/foundation-cli +``` + +Production builds can then exclude developer dependencies: + +```shell +composer install --no-dev --classmap-authoritative +``` + +If generated code extends a runtime Foundation class, install that runtime package normally. For example, generated WP-CLI commands require `stellarwp/foundation-wpcli` in `require`. + +## Install every component + +Install the aggregate package when one application intentionally owns the complete Foundation installation and expects to use most components: + +```shell +composer require stellarwp/foundation +``` + +This provides every runtime component and the developer CLI at `vendor/bin/foundation`. It is convenient for a complete application or development environment, but it is not the lean installation path for a distributable WordPress plugin. + +## Load Composer before Foundation + +Load Composer's generated autoloader from the application entrypoint before constructing the container or registering providers: + +```php + [ + 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? 'your-plugin', + ], + 'admin_notice' => [ + 'capability' => $_ENV['ADMIN_NOTICE_CAPABILITY'] ?? 'manage_options', + ], +]; +``` + +Application classes should receive resolved values through constructor injection rather than reading `$_ENV` or the configuration object directly. + +## Create the feature class + +Create `src/Admin_Notice/Notice.php`. The notice receives the capability supplied by its provider, while user-facing text remains in the feature so WordPress can translate it: + +```php title="Notice.php" +capability ) === '' ) { + throw new InvalidArgumentException( 'The admin notice capability cannot be empty.' ); + } + } + + /** + * Display the configured notice. + * + * @action admin_notices + */ + public function display(): void { + if ( ! current_user_can( $this->capability ) ) { + return; + } + + printf( + '

%s

', + esc_html__( 'Your plugin is ready.', 'your-plugin' ) + ); + } +} +``` + +The `@action admin_notices` annotation records why WordPress calls `display()`. The class itself does not register global hooks or resolve dependencies from the container. + +## Register the complete feature + +In `src/Admin_Notice/Provider.php`, alias the Foundation base provider because it shares the `Provider` short name. Keep the feature's definitions and hooks together in one focused registration method: + +```php title="Provider.php" +register_admin_notice(); + } + + private function register_admin_notice(): void { + $this->container->when( Notice::class ) + ->needs( '$capability' ) + ->give( (string) $this->config->get( 'admin_notice.capability' ) ); + + $this->container->singleton( Notice::class ); + + add_action( + 'admin_notices', + $this->container->callback( Notice::class, 'display' ) + ); + } +} +``` + +The provider registers the hook before resolving `Notice`. WordPress creates the notice through the container only when `admin_notices` runs. + +As a provider grows, add methods named for the feature or capability they configure, such as `register_admin_notice()` or `register_report_export()`. Avoid grouping unrelated work under methods such as `register_bindings()` or `register_hooks()` merely because it uses the same API. + +## Add the provider to the application + +Register the feature in the ordered provider list in `src/App.php`: + +```php title="App.php" +use StellarWP\Foundation\Container\Contracts\Providable; +use YourPlugin\Admin_Notice; + +/** @var list> */ +private const array PROVIDERS = [ + Admin_Notice\Provider::class, +]; +``` + +Register infrastructure providers before feature providers that consume them. Avoid resolving application services while providers are still registering; complete the container graph before WordPress invokes its feature entrypoints. + +For a larger feature, its top-level `Provider` may register internal providers so `App` only needs to know the feature entrypoint. Keep that provider as a pure composition boundary: if it registers other providers, it should not also contain bindings, configuration, hooks, or feature behavior. Providers shared across features still belong in the application's ordered provider list. + +## Continue + +[Scope Foundation to the application](/start/scope-foundation/) before using shared WordPress resources. diff --git a/src/Docs/src/content/docs/start/scope-foundation.md b/src/Docs/src/content/docs/start/scope-foundation.md new file mode 100644 index 0000000..32da47b --- /dev/null +++ b/src/Docs/src/content/docs/start/scope-foundation.md @@ -0,0 +1,73 @@ +--- +title: Scope Foundation to Your Application +description: Decide whether Foundation-managed commands and database resources need an application prefix. +sidebar: + order: 6 +--- + +The Foundation prefix gives shared resources an application identity. It affects resources that exist outside PHP's class namespace, including WP-CLI command names, migration tables, and migration locks. + +## Decide whether you need a prefix + +:::tip[Building the complete WordPress application?] +If one project owns the WordPress installation, its themes, its plugins, and the Foundation composition root, you generally do not need to configure a prefix. Foundation's default `nx` prefix gives that application one shared resource identity. + +This is appropriate when the deployed code is intentionally one application and Foundation is configured centrally rather than bundled independently by its features. +::: + +:::caution[Building a standalone plugin?] +A plugin distributed for installation on WordPress sites must configure its own stable prefix. It can run alongside unrelated plugins that also bundle Foundation, and those plugins must not share its commands, migration history, or locks. +::: + +PHP namespace prefixing tools such as Strauss do not solve this problem. They isolate PHP classes, but WordPress database tables and WP-CLI command names still share the same installation-wide namespace. + +## Configure a standalone plugin + +Set `foundation.prefix` in the plugin's `config.php`: + +```php title="config.php" + [ + 'prefix' => $_ENV['FOUNDATION_PREFIX'] ?? 'your-plugin', + ], +]; +``` + +Use a stable lowercase kebab-case value based on the plugin's permanent identity: + +```text +your-plugin +``` + +Do not derive the prefix from a display name, installation directory, release version, or other value that may change. Changing it later makes Foundation look for a different set of resources. + +## Understand what it changes + +Foundation adapts the prefix to the format required by each package. Given `your-plugin`: + +- WP-CLI commands are registered under `wp your-plugin`. +- The migration ledger defaults to `your_plugin_foundation_migrations`. +- Database-backed migration locks default to `your_plugin_foundation_locks`. +- The migration lock name defaults to `your-plugin-foundation-database-migrations`. + +Without configuration, those resources use `nx`. That is convenient for an application that owns the installation, but unsafe for a standalone plugin because another Foundation consumer may use the same defaults. + +## Keep the prefix stable + +Treat the prefix as persisted application identity. Once a release has created migration tables or registered operational commands, changing the prefix can make existing migrations appear missing and can leave the old resources behind. + +:::note +Different environments may use different databases, but a deployed environment should retain the same prefix for the lifetime of its Foundation-managed resources. +::: + +## Override one package when necessary + +Package-specific configuration takes precedence over names derived from `foundation.prefix`. Use an override when an existing installation must retain a previously published table, lock, or command name. + +New applications and plugins should normally configure only `foundation.prefix` and allow Foundation packages to derive consistent defaults. + +## Continue + +[Configure application locks](/components/lock/) when work must not overlap across requests, workers, or servers. diff --git a/src/Docs/src/content/docs/start/what-is-foundation.md b/src/Docs/src/content/docs/start/what-is-foundation.md new file mode 100644 index 0000000..3179821 --- /dev/null +++ b/src/Docs/src/content/docs/start/what-is-foundation.md @@ -0,0 +1,52 @@ +--- +title: What Foundation Is +description: Understand when to use the Foundation aggregate package or one of its focused components. +sidebar: + order: 1 +--- + +Foundation is a Composer monorepo of reusable PHP components maintained for libraries and WordPress plugin ecosystems. It provides common application infrastructure without requiring every project to invent its own container, logging, locking, database, identifier, pipeline, shutdown, view, or command conventions. + +Foundation is primarily developed for internal Nexcess projects. Its packages are publicly available and designed to remain reusable, but the needs of Nexcess applications will primarily drive changes, priorities, and the project roadmap. + +Each component is published to its own read-only repository. A project can install the aggregate `stellarwp/foundation` package or require only the components it uses. + +:::caution[Choose dependencies before installing] +For a distributable WordPress plugin, install split runtime packages and require `stellarwp/foundation-cli` with `--dev`. The aggregate package includes the developer CLI in its normal installation; `composer install --no-dev` does not remove it. +::: + +## Choose individual components + +Use split packages when a library or production plugin should ship with the smallest practical dependency set. + +```shell +composer require stellarwp/foundation-container stellarwp/foundation-log +composer require --dev stellarwp/foundation-cli +``` + +Runtime features belong in Composer's `require` section. Install `stellarwp/foundation-cli` in `require-dev` when it is used only to generate project code. + +## Choose the aggregate package + +Use `stellarwp/foundation` when convenience is more important than minimizing installed code. It provides every component and the `vendor/bin/foundation` developer CLI. + +```shell +composer require stellarwp/foundation +``` + +The aggregate package is appropriate for complete applications or development environments that intentionally own the whole Foundation installation. Prefer split packages for production plugin archives. + +## Treat Foundation as application infrastructure + +Foundation components provide infrastructure and extension points. Application-specific decisions still belong to the consuming project: + +- Select and configure the components the application needs. +- Register application services through focused providers. +- Set a unique application prefix for shared WordPress resources. +- Keep authorization, business rules, and domain behavior in application code. + +Foundation does not require an application to adopt every component at once. + +## Continue + +[Install Foundation](/start/install-foundation/) using the approach that matches the application. diff --git a/src/Docs/src/content/docs/tooling/foundation-cli.mdx b/src/Docs/src/content/docs/tooling/foundation-cli.mdx new file mode 100644 index 0000000..997246b --- /dev/null +++ b/src/Docs/src/content/docs/tooling/foundation-cli.mdx @@ -0,0 +1,234 @@ +--- +title: Foundation CLI +description: Generate Foundation-aware WordPress classes and build project-specific developer commands. +sidebar: + order: 1 +--- + +import { CardGrid, LinkCard } from '@astrojs/starlight/components'; + +Foundation CLI is development tooling for generating project code. It reads the consuming project's Composer configuration, follows WordPress naming and formatting conventions, and uses stubs owned by the runtime package that defines each generated API. + +## Installation + +Install the CLI as a development dependency in a consuming project: + +```shell +composer require --dev stellarwp/foundation-cli +``` + +List its available commands: + +```shell +vendor/bin/foundation list +``` + +:::caution[Keep the CLI out of production plugin archives] +`stellarwp/foundation-cli` includes Symfony Console, generators, and scaffolding assets. Install it with `--dev` and build standalone plugin archives with `composer install --no-dev`. Generated classes depend on their runtime Foundation package, not on the generator. + +The aggregate `stellarwp/foundation` package includes the CLI in its normal installation. Require individual split packages when a production archive must remain lean. +::: + +Do not register `StellarWP\Foundation\Cli\CliProvider` in the WordPress application's provider list. It boots the Symfony Console application for the `foundation` executable and is unrelated to WordPress request bootstrap. + +### Add a Composer script + +The binary can be exposed through a project script in `composer.json`: + +```json title="composer.json" +{ + "scripts": { + "foundation": "@php vendor/bin/foundation" + } +} +``` + +Pass command arguments after `--`: + +```shell +composer run foundation -- list +``` + +## Generate project code + +### Generate a WP-CLI command + +```shell +vendor/bin/foundation make:wpcli-command Sync_Products_Command +``` + +The generated class extends Foundation's WP-CLI command base and demonstrates positional arguments, associative options, and flags. A command shipped by the plugin requires the runtime package: + +```shell +composer require stellarwp/foundation-wpcli +``` + + + +### Generate a database feature + +Generate the application provider first so later generators can register the table and migration automatically: + +```shell +vendor/bin/foundation make:database-provider +vendor/bin/foundation make:database-table Reports_Table +vendor/bin/foundation make:database-migration Create_Reports_Table +``` + +Generated database classes require the runtime package: + +```shell +composer require stellarwp/foundation-database +``` + +Database table and migration generators refuse to overwrite existing files. Edit an unapplied migration directly, or create a new migration after the existing one has been deployed. + + + +### Inspect command options + +Use Symfony Console's built-in help for supported names, paths, namespaces, and feature-specific options: + +```shell +vendor/bin/foundation help make:wpcli-command +vendor/bin/foundation help make:database-provider +vendor/bin/foundation help make:database-table +vendor/bin/foundation help make:database-migration +``` + +The generators use the first `autoload.psr-4` entry in the project's `composer.json` to determine the default namespace and source path. Explicit `--namespace` and `--path` options override those defaults. + +## Customize generation + +### Override package stubs + +Place project-specific stubs under `foundation/stubs/` using the same feature path as the package default: + +```text +foundation/stubs/ + wpcli/ + command.stub + database/ + provider.stub + table.stub + table-migration.stub + migration.stub +``` + +Copy the package's default stub before customizing it so required placeholders remain available. Local scaffolding assets that should not ship in a production zip should be excluded in the consuming project's `.gitattributes`. + +### Generate Strauss-compatible imports + +When the consuming project's `composer.json` defines `extra.strauss.namespace_prefix`, generators apply that prefix to Foundation imports. For example, a configured `YourPlugin\\` prefix changes: + +```php +use StellarWP\Foundation\WPCli\Command; +``` + +to: + +```php +use YourPlugin\StellarWP\Foundation\WPCli\Command; +``` + +This keeps generated classes compatible when Strauss prefixes dependencies without updating project call sites. Handwritten imports remain the application's responsibility. + +## Build a project-specific CLI + +The installed `vendor/bin/foundation` executable contains Foundation's commands. A project that needs its own Symfony Console commands can create a separate executable using `StellarWP\Foundation\Cli\Application`. + +In `src/Cli/Cache_Clear_Command.php`: + +```php title="Cache_Clear_Command.php" +writeln( 'Cache cleared.' ); + + return Command::SUCCESS; + } +} +``` + +In `src/Cli/Command_Provider.php`: + +```php title="Command_Provider.php" +command; + } +} +``` + +Then create the project's executable, for example `bin/your-plugin`: + +```php title="your-plugin" +#!/usr/bin/env php +run() ); +``` + +This small example has no application dependencies. When commands need services, construct the command provider through the project's container instead of creating dependencies inside command classes. + +## Foundation monorepo maintenance + +:::danger[Not a consuming-project command] +`package:create` exists only for maintainers working inside the Foundation monorepo. It creates split-package scaffolding and configures read-only GitHub repositories; it is not a general package generator. +::: + +Preview the repository actions without changing GitHub: + +```shell +composer run foundation -- package:create Log +``` + +Pass `--apply` only after reviewing the generated actions: + +```shell +composer run foundation -- package:create Log --apply +``` + +The command can create `src/` scaffolding, asks for the Composer package name, and runs `composer monorepo merge` after local package creation. diff --git a/src/Docs/src/content/i18n/en.json b/src/Docs/src/content/i18n/en.json new file mode 100644 index 0000000..7936149 --- /dev/null +++ b/src/Docs/src/content/i18n/en.json @@ -0,0 +1,3 @@ +{ + "page.editLink": "Help improve this page" +} diff --git a/src/Docs/src/styles/custom.css b/src/Docs/src/styles/custom.css new file mode 100644 index 0000000..c7a9590 --- /dev/null +++ b/src/Docs/src/styles/custom.css @@ -0,0 +1,62 @@ +:root { + --sl-color-accent-low: #dce8ff; + --sl-color-accent: #0a55cc; + --sl-color-accent-high: #062f7a; + --sl-content-width: 52rem; + --foundation-code-bg: #f3f5f7; + --foundation-code-title-bg: #e9edf2; + --foundation-code-border: #cfd5dd; +} + +:root[data-theme='dark'] { + --sl-color-accent-low: #112c5c; + --sl-color-accent: #3d7bf5; + --sl-color-accent-high: #dce8ff; + --foundation-code-bg: #171c26; + --foundation-code-title-bg: #111620; + --foundation-code-border: #343b49; +} + +:root[data-theme='light'] { + --sl-color-bg: #fefbf2; + --sl-color-bg-nav: #fefbf2; + --sl-color-bg-sidebar: #f8f4e9; +} + +.hero .actions a[href='/start/what-is-foundation/'] { + background: #0a55cc; + color: #fefbf2; +} + +.hero .actions a[href='/start/what-is-foundation/']:hover { + background: #3d7bf5; +} + +a[aria-current='page'] { + background: #0a55cc; + color: #fefbf2; +} + +.sl-markdown-content h2 { + border-top: 1px solid var(--sl-color-gray-5); + padding-top: 1.5rem; +} + +.sl-markdown-content h2:first-of-type { + border-top: 0; + padding-top: 0; +} + +.sl-markdown-content [data-nova-code-container] { + background: var(--foundation-code-bg); + border-color: var(--foundation-code-border); +} + +.sl-markdown-content [data-nova-code-container] pre.astro-code { + background: var(--foundation-code-bg) !important; +} + +.sl-markdown-content [data-nova-code-container][data-nova-code-title] > div:first-child { + background: var(--foundation-code-title-bg); + border-color: var(--foundation-code-border); +} diff --git a/src/Docs/tsconfig.json b/src/Docs/tsconfig.json new file mode 100644 index 0000000..bcbf8b5 --- /dev/null +++ b/src/Docs/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "astro/tsconfigs/strict" +} diff --git a/src/Identifier/.gitattributes b/src/Identifier/.gitattributes new file mode 100644 index 0000000..e82014a --- /dev/null +++ b/src/Identifier/.gitattributes @@ -0,0 +1,7 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore paths when git creates an archive of this package +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore diff --git a/src/Identifier/.github/workflows/close-pull-request.yml b/src/Identifier/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..ddbc0e9 --- /dev/null +++ b/src/Identifier/.github/workflows/close-pull-request.yml @@ -0,0 +1,16 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + pull-requests: write + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.

Thanks!" diff --git a/src/Identifier/.gitignore b/src/Identifier/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/src/Identifier/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/src/Identifier/Contracts/IdentifierGenerator.php b/src/Identifier/Contracts/IdentifierGenerator.php new file mode 100644 index 0000000..c7c44da --- /dev/null +++ b/src/Identifier/Contracts/IdentifierGenerator.php @@ -0,0 +1,11 @@ +container->singleton(RandomizerEntropy::class); + $this->container->singleton(SystemMillisecondClock::class); + $this->container->singleton(UlidGenerator::class); + $this->container->singleton(UlidValidator::class); + + $this->container->when(RandomizerEntropy::class) + ->needs(Randomizer::class) + ->give(static fn (): Randomizer => new Randomizer(new Secure())); + + $this->container->bind(Entropy::class, static fn (C $c): RandomizerEntropy => $c->get(RandomizerEntropy::class)); + $this->container->bind(MillisecondClock::class, static fn (C $c): SystemMillisecondClock => $c->get(SystemMillisecondClock::class)); + $this->container->bind(UlidGeneratorContract::class, static fn (C $c): UlidGenerator => $c->get(UlidGenerator::class)); + } +} diff --git a/src/Identifier/README.md b/src/Identifier/README.md new file mode 100644 index 0000000..646f699 --- /dev/null +++ b/src/Identifier/README.md @@ -0,0 +1,18 @@ +# Foundation Identifier + +> [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +Foundation Identifier provides injectable contracts for string identifiers, +with secure ULID generation and canonical ULID validation. + +## Installation + +```shell +composer require stellarwp/foundation-identifier +``` + +## Documentation + +See the [Foundation Identifier documentation](https://foundation.nexcess.dev/components/identifier/) +for provider configuration, generation, validation, ordering, and testing. diff --git a/src/Identifier/Ulid/Contracts/Entropy.php b/src/Identifier/Ulid/Contracts/Entropy.php new file mode 100644 index 0000000..7cb3aea --- /dev/null +++ b/src/Identifier/Ulid/Contracts/Entropy.php @@ -0,0 +1,16 @@ +randomizer->getBytes($length); + } +} diff --git a/src/Identifier/Ulid/SystemMillisecondClock.php b/src/Identifier/Ulid/SystemMillisecondClock.php new file mode 100644 index 0000000..a064299 --- /dev/null +++ b/src/Identifier/Ulid/SystemMillisecondClock.php @@ -0,0 +1,15 @@ +encodeTimestamp($this->clock->milliseconds()) + . $this->encodeRandomness($this->entropy->bytes(self::RANDOM_BYTES)); + } + + private function encodeTimestamp(int $timestamp): string { + if ($timestamp < 0 || $timestamp > self::MAX_TIMESTAMP) { + throw new OutOfRangeException(sprintf('ULID timestamps must be between 0 and %d milliseconds.', self::MAX_TIMESTAMP)); + } + + $encoded = ''; + + for ($i = 0; $i < self::TIMESTAMP_LENGTH; $i++) { + $encoded = self::ALPHABET[$timestamp % 32] . $encoded; + $timestamp = intdiv($timestamp, 32); + } + + return $encoded; + } + + private function encodeRandomness(string $bytes): string { + if (strlen($bytes) !== self::RANDOM_BYTES) { + throw new RuntimeException(sprintf('ULID generation requires exactly %d random bytes.', self::RANDOM_BYTES)); + } + + $encoded = ''; + $buffer = 0; + $bitCount = 0; + + for ($i = 0; $i < self::RANDOM_BYTES; $i++) { + $buffer = ($buffer << 8) | ord($bytes[$i]); + $bitCount += 8; + + while ($bitCount >= 5) { + $bitCount -= 5; + $encoded .= self::ALPHABET[($buffer >> $bitCount) & 31]; + $buffer &= (1 << $bitCount) - 1; + } + } + + return $encoded; + } +} diff --git a/src/Identifier/Ulid/UlidValidator.php b/src/Identifier/Ulid/UlidValidator.php new file mode 100644 index 0000000..e111db4 --- /dev/null +++ b/src/Identifier/Ulid/UlidValidator.php @@ -0,0 +1,15 @@ +=8.3", + "arokettu/random-polyfill": ">=1.0.6 <1.99", + "stellarwp/foundation-container": "^2.0" + }, + "autoload": { + "psr-4": { + "StellarWP\\Foundation\\Identifier\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev" + } + } +} diff --git a/src/Lock/.gitattributes b/src/Lock/.gitattributes new file mode 100644 index 0000000..e82014a --- /dev/null +++ b/src/Lock/.gitattributes @@ -0,0 +1,7 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore paths when git creates an archive of this package +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore diff --git a/src/Lock/.github/workflows/close-pull-request.yml b/src/Lock/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..ddbc0e9 --- /dev/null +++ b/src/Lock/.github/workflows/close-pull-request.yml @@ -0,0 +1,16 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + pull-requests: write + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.

Thanks!" diff --git a/src/Lock/.gitignore b/src/Lock/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/src/Lock/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/src/Lock/Contracts/Clock.php b/src/Lock/Contracts/Clock.php new file mode 100644 index 0000000..7fc8329 --- /dev/null +++ b/src/Lock/Contracts/Clock.php @@ -0,0 +1,16 @@ + + */ + private array $locks = []; + + public function __construct( + private readonly Clock $clock = new SystemClock() + ) { + } + + /** + * {@inheritDoc} + */ + public function acquire(string $name, int $ttl): ?LockToken { + $this->assertValidName($name); + $this->assertValidLockTtl($ttl); + $this->releaseIfExpired($name); + + if (isset($this->locks[$name])) { + return null; + } + + $token = new LockToken( + name: $name, + owner: $this->generateLockOwner(), + expiresAt: $this->calculateLockExpiration($this->clock->now(), $ttl) + ); + + $this->locks[$name] = $token; + + return $token; + } + + public function release(LockToken $token): bool { + $this->releaseIfExpired($token->name); + + if (! isset($this->locks[$token->name]) || ! $this->locks[$token->name]->matches($token)) { + return false; + } + + unset($this->locks[$token->name]); + + return true; + } + + /** + * {@inheritDoc} + */ + public function refresh(LockToken $token, int $ttl): ?LockToken { + $this->assertValidLockTtl($ttl); + $this->releaseIfExpired($token->name); + + if (! isset($this->locks[$token->name]) || ! $this->locks[$token->name]->matches($token)) { + return null; + } + + $refreshed = $token->withExpiration( + $this->calculateLockExpiration($this->clock->now(), $ttl) + ); + + $this->locks[$token->name] = $refreshed; + + return $refreshed; + } + + public function isAcquired(string $name): bool { + $this->assertValidName($name); + $this->releaseIfExpired($name); + + return isset($this->locks[$name]); + } + + private function releaseIfExpired(string $name): void { + if (! isset($this->locks[$name])) { + return; + } + + if (! $this->locks[$name]->isExpired($this->clock->now())) { + return; + } + + unset($this->locks[$name]); + } + + /** + * @throws InvalidArgumentException + */ + private function assertValidName(string $name): void { + if (trim($name) === '') { + throw new InvalidArgumentException('Lock name cannot be empty.'); + } + } +} diff --git a/src/Lock/LockToken.php b/src/Lock/LockToken.php new file mode 100644 index 0000000..c4c9341 --- /dev/null +++ b/src/Lock/LockToken.php @@ -0,0 +1,61 @@ +name) === '') { + throw new InvalidArgumentException('Lock name cannot be empty.'); + } + + if (trim($this->owner) === '') { + throw new InvalidArgumentException('Lock owner cannot be empty.'); + } + } + + /** + * Determine whether the token has expired at the provided time. + * + * When no time is provided, the local system clock is used as a convenience + * check. Lock implementations should pass their authoritative clock value. + */ + public function isExpired(?DateTimeImmutable $now = null): bool { + return $this->expiresAt <= ($now ?? new DateTimeImmutable()); + } + + /** + * Determine whether another token represents the same lock owner. + */ + public function matches(self $token): bool { + return $this->name === $token->name && $this->owner === $token->owner; + } + + /** + * Return a new token for the same owner with the provided expiration time. + */ + public function withExpiration(DateTimeImmutable $expiresAt): self { + return new self( + name: $this->name, + owner: $this->owner, + expiresAt: $expiresAt + ); + } +} diff --git a/src/Lock/README.md b/src/Lock/README.md new file mode 100644 index 0000000..4ea09db --- /dev/null +++ b/src/Lock/README.md @@ -0,0 +1,20 @@ +# Foundation Lock + +> [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +Foundation Lock provides the shared lock contract, ownership tokens, and an +in-memory implementation for tests and single-process work. Persistent database +and Redis implementations are available through separate Foundation packages. + +## Installation + +```shell +composer require stellarwp/foundation-lock +``` + +## Documentation + +See the [Foundation Lock documentation](https://foundation.nexcess.dev/components/lock/) +for backend selection, configuration, lease handling, failure behavior, usage +examples, and testing. diff --git a/src/Lock/SystemClock.php b/src/Lock/SystemClock.php new file mode 100644 index 0000000..5d03eea --- /dev/null +++ b/src/Lock/SystemClock.php @@ -0,0 +1,16 @@ +add(new DateInterval(sprintf('PT%dS', $ttl))); + } catch (DateMalformedIntervalStringException $exception) { + throw new InvalidArgumentException('Lock TTL cannot be represented.', 0, $exception); + } + } +} diff --git a/src/Lock/Traits/GeneratesLockOwner.php b/src/Lock/Traits/GeneratesLockOwner.php new file mode 100644 index 0000000..9bfd123 --- /dev/null +++ b/src/Lock/Traits/GeneratesLockOwner.php @@ -0,0 +1,23 @@ +=8.3" + }, + "autoload": { + "psr-4": { + "StellarWP\\Foundation\\Lock\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev" + } + } +} diff --git a/src/LockRedis/.gitattributes b/src/LockRedis/.gitattributes new file mode 100644 index 0000000..e82014a --- /dev/null +++ b/src/LockRedis/.gitattributes @@ -0,0 +1,7 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore paths when git creates an archive of this package +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore diff --git a/src/LockRedis/.github/workflows/close-pull-request.yml b/src/LockRedis/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..ddbc0e9 --- /dev/null +++ b/src/LockRedis/.github/workflows/close-pull-request.yml @@ -0,0 +1,16 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + pull-requests: write + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.

Thanks!" diff --git a/src/LockRedis/.gitignore b/src/LockRedis/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/src/LockRedis/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/src/LockRedis/Connections/PhpRedisConnection.php b/src/LockRedis/Connections/PhpRedisConnection.php new file mode 100644 index 0000000..d95ab8d --- /dev/null +++ b/src/LockRedis/Connections/PhpRedisConnection.php @@ -0,0 +1,74 @@ +command( + static fn (Redis $redis): mixed => $redis->eval($script, [...$keys, ...$arguments], count($keys)) + ); + + if (! is_int($result)) { + throw new LockUnavailableException('PhpRedis returned an unexpected EVAL response.'); + } + + return $result; + } + + /** + * @throws LockUnavailableException When PhpRedis cannot determine whether the key exists. + */ + public function exists(string $key): bool { + $result = $this->command( + static fn (Redis $redis): Redis|int|bool => $redis->exists($key) + ); + + if (! is_int($result)) { + throw new LockUnavailableException('PhpRedis returned an unexpected EXISTS response.'); + } + + return $result > 0; + } + + /** + * @template T + * + * @param callable(Redis): T $command + * + * @throws LockUnavailableException When PhpRedis reports an exception or command error. + * + * @return T + */ + private function command(callable $command): mixed { + try { + $this->redis->clearLastError(); + $result = $command($this->redis); + $error = $this->redis->getLastError(); + } catch (RedisException $exception) { + throw new LockUnavailableException('PhpRedis could not execute the lock operation.', 0, $exception); + } + + if ($error !== null) { + throw new LockUnavailableException(sprintf('PhpRedis could not execute the lock operation: %s', $error)); + } + + return $result; + } +} diff --git a/src/LockRedis/Connections/PredisConnection.php b/src/LockRedis/Connections/PredisConnection.php new file mode 100644 index 0000000..fb273d0 --- /dev/null +++ b/src/LockRedis/Connections/PredisConnection.php @@ -0,0 +1,65 @@ +command('EVAL', [$script, count($keys), ...$keys, ...$arguments]); + + if (! is_int($result)) { + throw new LockUnavailableException('Predis returned an unexpected EVAL response.'); + } + + return $result; + } + + /** + * @throws LockUnavailableException When Predis cannot determine whether the key exists. + */ + public function exists(string $key): bool { + $result = $this->command('EXISTS', [$key]); + + if (! is_int($result)) { + throw new LockUnavailableException('Predis returned an unexpected EXISTS response.'); + } + + return $result > 0; + } + + /** + * @param list $arguments + * + * @throws LockUnavailableException When Predis reports an exception or command error. + */ + private function command(string $name, array $arguments): mixed { + try { + $result = $this->redis->executeCommand($this->redis->createCommand($name, $arguments)); + } catch (PredisException $exception) { + throw new LockUnavailableException('Predis could not execute the lock operation.', 0, $exception); + } + + if ($result instanceof ErrorInterface) { + throw new LockUnavailableException(sprintf('Predis could not execute the lock operation: %s', $result->getMessage())); + } + + return $result; + } +} diff --git a/src/LockRedis/Contracts/Connection.php b/src/LockRedis/Contracts/Connection.php new file mode 100644 index 0000000..4df9ebf --- /dev/null +++ b/src/LockRedis/Contracts/Connection.php @@ -0,0 +1,28 @@ + $keys + * @param list $arguments + * + * @throws LockUnavailableException When Redis cannot determine the result. + */ + public function evaluate(string $script, array $keys, array $arguments): int; + + /** + * Determine whether the Redis key exists. + * + * @throws LockUnavailableException When Redis cannot determine the result. + */ + public function exists(string $key): bool; +} diff --git a/src/LockRedis/LockRedisProvider.php b/src/LockRedis/LockRedisProvider.php new file mode 100644 index 0000000..fdc1c95 --- /dev/null +++ b/src/LockRedis/LockRedisProvider.php @@ -0,0 +1,55 @@ +registerConfiguration(); + $this->registerClock(); + $this->registerLock(); + } + + /** + * @throws InvalidArgumentException When the required Redis lock prefix is not configured. + */ + private function registerConfiguration(): void { + $prefix = $this->config->get('lock.redis.prefix'); + + if (! is_string($prefix) || trim($prefix) === '') { + throw new InvalidArgumentException('The lock.redis.prefix configuration value must be a non-empty string.'); + } + + $this->container->singleton(self::PREFIX, $prefix); + } + + private function registerClock(): void { + $this->container->singleton(SystemClock::class); + } + + private function registerLock(): void { + $this->container->when(RedisLock::class) + ->needs(Clock::class) + ->give(static fn (C $c): SystemClock => $c->get(SystemClock::class)); + + $this->container->when(RedisLock::class) + ->needs('$prefix') + ->give(static fn (C $c): string => $c->get(self::PREFIX)); + + $this->container->singleton(RedisLock::class); + } +} diff --git a/src/LockRedis/README.md b/src/LockRedis/README.md new file mode 100644 index 0000000..53f3569 --- /dev/null +++ b/src/LockRedis/README.md @@ -0,0 +1,28 @@ +# Foundation Lock Redis + +> [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +Redis-backed expiring locks for Foundation. This package implements the shared +`stellarwp/foundation-lock` contract using atomic Redis acquisition, release, +and refresh operations. + +## Installation + +```shell +composer require stellarwp/foundation-lock-redis +``` + +Install one supported Redis client: + +```shell +composer require "predis/predis:>=3.0 <4.0" +``` + +Alternatively, install and enable the PhpRedis extension. + +## Documentation + +See the [Foundation Lock guide](https://foundation.nexcess.dev/components/lock/) +for backend selection, Redis configuration, container registration, lease +handling, failure behavior, and usage examples. diff --git a/src/LockRedis/RedisLock.php b/src/LockRedis/RedisLock.php new file mode 100644 index 0000000..62ad975 --- /dev/null +++ b/src/LockRedis/RedisLock.php @@ -0,0 +1,145 @@ +prefix) === '') { + throw new InvalidArgumentException('Redis lock prefix cannot be empty.'); + } + } + + /** + * {@inheritDoc} + */ + public function acquire(string $name, int $ttl): ?LockToken { + $this->assertValidName($name); + $this->assertValidLockTtl($ttl); + + $startedAt = $this->clock->now(); + $expiresAt = $this->calculateLockExpiration($startedAt, $ttl); + $owner = $this->generateLockOwner(); + $result = $this->connection->evaluate( + self::ACQUIRE_SCRIPT, + [$this->key($name)], + [$owner, $ttl] + ); + + return match ($result) { + 0 => null, + 1 => new LockToken( + name: $name, + owner: $owner, + expiresAt: $expiresAt + ), + default => throw new LockUnavailableException('Redis returned an unexpected acquisition result.'), + }; + } + + /** + * @throws LockUnavailableException When Redis cannot determine the release result. + */ + public function release(LockToken $token): bool { + return match ($this->connection->evaluate( + self::RELEASE_SCRIPT, + [$this->key($token->name)], + [$token->owner] + )) { + 0 => false, + 1 => true, + default => throw new LockUnavailableException('Redis returned an unexpected release result.'), + }; + } + + /** + * {@inheritDoc} + */ + public function refresh(LockToken $token, int $ttl): ?LockToken { + $this->assertValidLockTtl($ttl); + + $startedAt = $this->clock->now(); + $expiresAt = $this->calculateLockExpiration($startedAt, $ttl); + $result = $this->connection->evaluate( + self::REFRESH_SCRIPT, + [$this->key($token->name)], + [$token->owner, $ttl] + ); + + return match ($result) { + 0 => null, + 1 => $token->withExpiration($expiresAt), + default => throw new LockUnavailableException('Redis returned an unexpected refresh result.'), + }; + } + + /** + * @throws InvalidArgumentException When the lock name is empty. + * @throws LockUnavailableException When Redis cannot determine whether the lock exists. + */ + public function isAcquired(string $name): bool { + $this->assertValidName($name); + + return $this->connection->exists($this->key($name)); + } + + private function key(string $name): string { + return $this->prefix . $name; + } + + /** + * @throws InvalidArgumentException When the lock name is empty. + */ + private function assertValidName(string $name): void { + if (trim($name) === '') { + throw new InvalidArgumentException('Lock name cannot be empty.'); + } + } +} diff --git a/src/LockRedis/composer.json b/src/LockRedis/composer.json new file mode 100644 index 0000000..3426364 --- /dev/null +++ b/src/LockRedis/composer.json @@ -0,0 +1,29 @@ +{ + "name": "stellarwp/foundation-lock-redis", + "type": "library", + "description": "Redis-backed locks for Foundation.", + "license": "GPL-2.0-or-later", + "config": { + "vendor-dir": "vendor", + "preferred-install": "dist" + }, + "require": { + "php": ">=8.3", + "stellarwp/foundation-container": "^2.0", + "stellarwp/foundation-lock": "^2.0" + }, + "suggest": { + "ext-redis": "Required to use PhpRedisConnection.", + "predis/predis": "Required to use PredisConnection (>=3.0 <4.0)." + }, + "autoload": { + "psr-4": { + "StellarWP\\Foundation\\LockRedis\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev" + } + } +} diff --git a/src/Log/.github/workflows/close-pull-request.yml b/src/Log/.github/workflows/close-pull-request.yml index 7c34d2e..a2353e6 100644 --- a/src/Log/.github/workflows/close-pull-request.yml +++ b/src/Log/.github/workflows/close-pull-request.yml @@ -2,7 +2,10 @@ name: Close Pull Request on: pull_request_target: - types: [opened] + types: [opened, reopened] + +permissions: + pull-requests: write jobs: run: diff --git a/src/Log/LogProvider.php b/src/Log/LogProvider.php index c587bf6..1bd2e35 100644 --- a/src/Log/LogProvider.php +++ b/src/Log/LogProvider.php @@ -24,7 +24,7 @@ */ final class LogProvider extends Provider { - public const string LOG_LEVEL = 'foundation.log.log_level'; + public const string LOG_LEVEL = self::class . '.log_level'; public const string CHANNEL_ERRORLOG = 'errorlog'; private const string CHANNEL_CONSOLE = 'console'; private const string CHANNEL_NULL = 'null'; diff --git a/src/Log/README.md b/src/Log/README.md index dd4baa0..a03fdd1 100644 --- a/src/Log/README.md +++ b/src/Log/README.md @@ -3,48 +3,17 @@ > [!WARNING] > **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). -A logging library using [Monolog](https://github.com/Seldaek/monolog) that implements the `Psr\Log\LoggerInterface` interface. +Foundation Log configures [Monolog](https://github.com/Seldaek/monolog) behind +the standard `Psr\Log\LoggerInterface`. It includes console, PHP error log, +stacked, and null channels. ## Installation - ```shell composer require stellarwp/foundation-log ``` -If using [stellarwp/foundation-container](https://github.com/stellarwp/foundation-container), create a `config.php` and register -it in the container with: - -```php -$this->container->bind(Dot::class, new Dot(require_once dirname(__FILE__) . '/config.php')); -``` - -The config.php file maps environment variables, either from an `.env` file if you configured [phpdotenv](https://github.com/vlucas/phpdotenv), or manually set, e.g. - -```php - [ - 'level' => $_ENV['APP_LOG_LEVEL'] ?? 'debug', - 'channel' => $_ENV['APP_LOG_CHANNEL'] ?? 'null', // console, errorlog, stack (both console and errorlog) or null - 'channels' => [ - 'errorlog' => [], - 'console' => [ - 'with' => [ - 'stream' => 'php://stdout', - ], - ], - 'stack' => [ - 'with' => [ - 'stream' => 'php://stdout', - ], - ], - ], - ], -]; -``` +## Documentation -Then, include the [LogProvider.php](./LogProvider.php) in your -application and call the `register` method. Anytime you inject a `Psr\Log\LoggerInterface` instance into another class, it will use -your provided configuration. +See the [Foundation Log documentation](https://foundation.nexcess.dev/components/log/) +for channel configuration, structured logging, failure behavior, and testing. diff --git a/src/Pipeline/.github/workflows/close-pull-request.yml b/src/Pipeline/.github/workflows/close-pull-request.yml index 7c34d2e..a2353e6 100644 --- a/src/Pipeline/.github/workflows/close-pull-request.yml +++ b/src/Pipeline/.github/workflows/close-pull-request.yml @@ -2,7 +2,10 @@ name: Close Pull Request on: pull_request_target: - types: [opened] + types: [opened, reopened] + +permissions: + pull-requests: write jobs: run: diff --git a/src/Pipeline/README.md b/src/Pipeline/README.md index 1f20a67..1414161 100644 --- a/src/Pipeline/README.md +++ b/src/Pipeline/README.md @@ -3,10 +3,17 @@ > [!WARNING] > **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). -A Pipeline / Command Design Pattern implementation based on [Laravel's Pipeline implementation](https://github.com/illuminate/pipeline/blob/master/Pipeline.php). +Foundation Pipeline passes values through ordered, container-resolved pipes. It +supports class, object, callable, and parameterized pipes and is based on +[Laravel's Pipeline implementation](https://github.com/illuminate/pipeline/blob/master/Pipeline.php). ## Installation ```shell composer require stellarwp/foundation-pipeline ``` + +## Documentation + +See the [Foundation Pipeline documentation](https://foundation.nexcess.dev/components/pipeline/) +for pipeline construction, transformations, short circuits, parameters, error handling, and testing. diff --git a/src/Shutdown/.gitattributes b/src/Shutdown/.gitattributes new file mode 100644 index 0000000..e82014a --- /dev/null +++ b/src/Shutdown/.gitattributes @@ -0,0 +1,7 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore paths when git creates an archive of this package +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore diff --git a/src/Shutdown/.github/workflows/close-pull-request.yml b/src/Shutdown/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..ddbc0e9 --- /dev/null +++ b/src/Shutdown/.github/workflows/close-pull-request.yml @@ -0,0 +1,16 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + pull-requests: write + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.

Thanks!" diff --git a/src/Shutdown/.gitignore b/src/Shutdown/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/src/Shutdown/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/src/Shutdown/Contracts/ShutdownRunner.php b/src/Shutdown/Contracts/ShutdownRunner.php new file mode 100644 index 0000000..d222d02 --- /dev/null +++ b/src/Shutdown/Contracts/ShutdownRunner.php @@ -0,0 +1,10 @@ + [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +Some work must happen before a PHP request ends but does not need to delay the +response sent to the client, such as flushing buffered logs or telemetry to a +third-party service. Foundation Shutdown lets features contribute that bounded +end-of-request work and attempts to finish supported HTTP responses before it +begins. + +Tasks run once in priority order, and one failed task does not prevent later tasks +from running. Shutdown work is best effort within the current PHP process; use a +durable queue for long-running work, retries, or work that cannot safely be lost. + +## Installation + +```shell +composer require stellarwp/foundation-shutdown +``` + +## Documentation + +See the [Foundation Shutdown documentation](https://foundation.nexcess.dev/components/shutdown/) +for provider registration, task contributions, priority ordering, response +finishing, failure behavior, and testing. diff --git a/src/Shutdown/ResponseFinishingRunner.php b/src/Shutdown/ResponseFinishingRunner.php new file mode 100644 index 0000000..49a6b39 --- /dev/null +++ b/src/Shutdown/ResponseFinishingRunner.php @@ -0,0 +1,43 @@ +terminated) { + return; + } + + $this->terminated = true; + + foreach (['fastcgi_finish_request', 'litespeed_finish_request'] as $finishRequest) { + if (! function_exists($finishRequest)) { + continue; + } + + try { + if ($finishRequest()) { + break; + } + } catch (Throwable) { + // Response finishing is best-effort and must not block termination work. + } + } + + $this->runner->terminate(); + } +} diff --git a/src/Shutdown/ShutdownProvider.php b/src/Shutdown/ShutdownProvider.php new file mode 100644 index 0000000..b6e5743 --- /dev/null +++ b/src/Shutdown/ShutdownProvider.php @@ -0,0 +1,49 @@ +registered) { + return; + } + + $this->registered = true; + + $this->container->when(ShutdownRunner::class) + ->needs('$tasks') + ->give(static fn (Container $container): array => $container->getVar(self::TASKS, [])); + + $this->container->singletonDecorators(ShutdownRunnerContract::class, [ + ResponseFinishingRunner::class, + ShutdownRunner::class, + ]); + + // Installation and uninstall requests may not have the complete application state expected by shutdown tasks. + if (defined('WP_UNINSTALL_PLUGIN') || wp_installing()) { + return; + } + + add_action( + 'shutdown', + $this->container->callback(ShutdownRunnerContract::class, 'terminate'), + PHP_INT_MAX + ); + } +} diff --git a/src/Shutdown/ShutdownRunner.php b/src/Shutdown/ShutdownRunner.php new file mode 100644 index 0000000..59ef64d --- /dev/null +++ b/src/Shutdown/ShutdownRunner.php @@ -0,0 +1,106 @@ + */ + private array $tasks; + + private bool $terminated = false; + + /** + * @param array $tasks + */ + public function __construct( + array $tasks = [], + private readonly ?LoggerInterface $logger = null + ) { + foreach ($tasks as $task) { + if (! $task instanceof ShutdownTask) { + throw new InvalidArgumentException('Shutdown tasks must be instances of ShutdownTask.'); + } + } + + $this->tasks = array_values($tasks); + } + + public function terminate(): void { + if ($this->terminated) { + return; + } + + $this->terminated = true; + + $tasks = $this->orderedTasks(); + + $this->log(LogLevel::DEBUG, 'Running shutdown tasks.', [ + 'task_count' => count($tasks), + ]); + + foreach ($tasks as $task) { + $context = [ + 'task' => $task->terminable::class, + 'priority' => $task->priority, + ]; + + $this->log(LogLevel::DEBUG, 'Running shutdown task.', $context); + + try { + $task->terminable->terminate(); + } catch (Throwable $exception) { + $this->log(LogLevel::ERROR, 'Shutdown task failed.', $context + [ + 'exception' => $exception, + ]); + } + } + } + + /** + * Logging must not interrupt application termination. + * + * @param array $context + */ + private function log(string $level, string $message, array $context = []): void { + try { + $this->logger?->log($level, $message, $context); + } catch (Throwable) { + // The remaining termination work is more important than diagnostics. + } + } + + /** + * @return list + */ + private function orderedTasks(): array { + /** @var list $indexedTasks */ + $indexedTasks = []; + + foreach ($this->tasks as $index => $task) { + $indexedTasks[] = [ + 'index' => $index, + 'task' => $task, + ]; + } + + usort( + $indexedTasks, + static fn (array $left, array $right): int => ($left['task']->priority <=> $right['task']->priority) + ?: ($left['index'] <=> $right['index']) + ); + + return array_map( + static fn (array $entry): ShutdownTask => $entry['task'], + $indexedTasks + ); + } +} diff --git a/src/Shutdown/ShutdownTask.php b/src/Shutdown/ShutdownTask.php new file mode 100644 index 0000000..39ba450 --- /dev/null +++ b/src/Shutdown/ShutdownTask.php @@ -0,0 +1,17 @@ +=8.3", + "psr/log": ">=1.0", + "stellarwp/foundation-container": "^2.0" + }, + "autoload": { + "psr-4": { + "StellarWP\\Foundation\\Shutdown\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev" + } + } +} diff --git a/src/View/.gitattributes b/src/View/.gitattributes new file mode 100644 index 0000000..e82014a --- /dev/null +++ b/src/View/.gitattributes @@ -0,0 +1,7 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore paths when git creates an archive of this package +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore diff --git a/src/View/.github/workflows/close-pull-request.yml b/src/View/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..ddbc0e9 --- /dev/null +++ b/src/View/.github/workflows/close-pull-request.yml @@ -0,0 +1,16 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened, reopened] + +permissions: + pull-requests: write + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.

Thanks!" diff --git a/src/View/.gitignore b/src/View/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/src/View/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/src/View/Contracts/DirectoryAwareView.php b/src/View/Contracts/DirectoryAwareView.php new file mode 100644 index 0000000..cdd2ddb --- /dev/null +++ b/src/View/Contracts/DirectoryAwareView.php @@ -0,0 +1,18 @@ + $data Values made available to the renderer. + * + * @throws Throwable When rendering fails. + */ + public function render(string $name, array $data = []): string; +} diff --git a/src/View/Exceptions/ViewNotFoundException.php b/src/View/Exceptions/ViewNotFoundException.php new file mode 100644 index 0000000..2d03e86 --- /dev/null +++ b/src/View/Exceptions/ViewNotFoundException.php @@ -0,0 +1,12 @@ +directory = $resolved; + } + + /** + * {@inheritDoc} + */ + public function withDirectory(string $directory): static { + return new self($directory); + } + + /** + * {@inheritDoc} + * + * @throws InvalidArgumentException When the view name is empty, absolute, or traverses parent directories. + * @throws RuntimeException When the view leaves output buffering in an invalid state. + * @throws ViewNotFoundException When the view does not exist, is unreadable, or resolves outside the configured directory. + * @throws \Throwable When the view itself throws. + */ + public function render(string $name, array $data = []): string { + $path = $this->resolve($name); + $bufferLevel = ob_get_level(); + $renderBufferLevel = $bufferLevel + 1; + $renderBufferTouched = false; + + ob_start(static function () use (&$renderBufferTouched): string { + $renderBufferTouched = true; + + return ''; + }); + + try { + self::renderFile($path, $data); + + if ($renderBufferTouched || ob_get_level() !== $renderBufferLevel) { + throw new RuntimeException(sprintf('The view "%s" must leave output buffering unchanged.', $name)); + } + + $output = ob_get_clean(); + + if ($output === false) { + throw new RuntimeException(sprintf('The output buffer for view "%s" could not be read.', $name)); + } + + return $output; + } finally { + self::discardBuffersAbove($bufferLevel); + } + } + + /** + * Render a PHP file in an isolated static scope with the supplied view data. + * + * @param array $foundationViewData + */ + private static function renderFile(string $foundationViewPath, array $foundationViewData): void { + extract($foundationViewData, EXTR_SKIP); + + require $foundationViewPath; + } + + /** + * Remove buffers opened while rendering without closing a caller-owned buffer. + * + * PHP cannot remove a buffer created without PHP_OUTPUT_HANDLER_REMOVABLE. + */ + private static function discardBuffersAbove(int $bufferLevel): void { + while (ob_get_level() > $bufferLevel) { + $status = ob_get_status(); + + if (($status['flags'] & PHP_OUTPUT_HANDLER_REMOVABLE) === 0 || ! ob_end_clean()) { + return; + } + } + } + + /** + * Resolve a relative view name to a readable PHP file inside the configured directory. + * + * @throws InvalidArgumentException When the view name is empty, absolute, or traverses parent directories. + * @throws ViewNotFoundException When the view cannot be safely resolved and read. + */ + private function resolve(string $name): string { + $this->validateName($name); + + $relative = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $name) . '.php'; + $path = realpath($this->directory . DIRECTORY_SEPARATOR . $relative); + + if ($path === false || ! is_file($path) || ! is_readable($path) || ! $this->contains($path)) { + throw new ViewNotFoundException(sprintf('The view "%s" could not be found in "%s".', $name, $this->directory)); + } + + return $path; + } + + /** + * Reject names that could select files outside the configured directory. + * + * @throws InvalidArgumentException When the name is empty, absolute, or contains a parent-directory segment. + */ + private function validateName(string $name): void { + if ( + trim($name) === '' + || str_contains($name, "\0") + || str_starts_with($name, '/') + || str_starts_with($name, '\\') + || preg_match('/^[A-Za-z]:[\\\\\/]/', $name) === 1 + ) { + throw new InvalidArgumentException('View names must be non-empty relative paths.'); + } + + $segments = preg_split('#[\\\\/]#', $name); + + if ($segments === false || in_array('..', $segments, true)) { + throw new InvalidArgumentException('View names cannot traverse parent directories.'); + } + } + + /** + * Determine whether a canonical file path remains inside the canonical view directory. + */ + private function contains(string $path): bool { + $directory = rtrim($this->directory, '/\\') . DIRECTORY_SEPARATOR; + + if (DIRECTORY_SEPARATOR === '\\') { + return str_starts_with(strtolower($path), strtolower($directory)); + } + + return str_starts_with($path, $directory); + } +} diff --git a/src/View/README.md b/src/View/README.md new file mode 100644 index 0000000..85322a5 --- /dev/null +++ b/src/View/README.md @@ -0,0 +1,18 @@ +# Foundation View + +> [!WARNING] +> **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). + +Foundation View renders trusted PHP templates to strings from a configured +directory and supports immutable runtime directory selection. + +## Installation + +```shell +composer require stellarwp/foundation-view +``` + +## Documentation + +See the [Foundation View documentation](https://foundation.nexcess.dev/components/view/) +for configuration, rendering, runtime directory selection, path safety, and testing. diff --git a/src/View/ViewProvider.php b/src/View/ViewProvider.php new file mode 100644 index 0000000..1ac238a --- /dev/null +++ b/src/View/ViewProvider.php @@ -0,0 +1,41 @@ +registerView(); + } + + /** + * @throws InvalidArgumentException When view.directory is not a non-empty string. + */ + private function registerView(): void { + $directory = $this->config->get('view.directory'); + + if (! is_string($directory) || trim($directory) === '') { + throw new InvalidArgumentException('The view.directory configuration value must be a non-empty string.'); + } + + $this->container->when(PhpView::class) + ->needs('$directory') + ->give($directory); + + $this->container->singleton(PhpView::class); + $this->container->singleton(View::class, static fn (C $c): PhpView => $c->get(PhpView::class)); + $this->container->singleton(DirectoryAwareView::class, static fn (C $c): PhpView => $c->get(PhpView::class)); + } +} diff --git a/src/View/composer.json b/src/View/composer.json new file mode 100644 index 0000000..14cc05c --- /dev/null +++ b/src/View/composer.json @@ -0,0 +1,24 @@ +{ + "name": "stellarwp/foundation-view", + "type": "library", + "description": "Render scoped PHP views to strings with immutable runtime directory selection.", + "license": "GPL-2.0-or-later", + "config": { + "vendor-dir": "vendor", + "preferred-install": "dist" + }, + "require": { + "php": ">=8.3", + "stellarwp/foundation-container": "^2.0" + }, + "autoload": { + "psr-4": { + "StellarWP\\Foundation\\View\\": "" + } + }, + "extra": { + "branch-alias": { + "dev-main": "2.0.x-dev" + } + } +} diff --git a/src/WPCli/.github/workflows/close-pull-request.yml b/src/WPCli/.github/workflows/close-pull-request.yml index 6bfbabe..ddbc0e9 100644 --- a/src/WPCli/.github/workflows/close-pull-request.yml +++ b/src/WPCli/.github/workflows/close-pull-request.yml @@ -2,7 +2,10 @@ name: Close Pull Request on: pull_request_target: - types: [opened] + types: [opened, reopened] + +permissions: + pull-requests: write jobs: run: diff --git a/src/WPCli/Command.php b/src/WPCli/Command.php index bf97272..0f82930 100644 --- a/src/WPCli/Command.php +++ b/src/WPCli/Command.php @@ -3,6 +3,7 @@ namespace StellarWP\Foundation\WPCli; use StellarWP\Foundation\Container\Contracts\Container; +use StellarWP\Foundation\WPCli\ValueObjects\CommandPrefix; use WP_CLI; use WP_CLI_Command; @@ -22,7 +23,7 @@ abstract class Command extends WP_CLI_Command public function __construct( protected Container $container, - private readonly string $commandPrefix + private readonly CommandPrefix $commandPrefix ) { parent::__construct(); } @@ -56,14 +57,20 @@ abstract protected function arguments(): array; * Register the command with WP-CLI. */ public function register(): void { - WP_CLI::add_command($this->command(), [$this, 'runCommand'], [ + WP_CLI::add_command($this->command(), function (array $args, array $assocArgs): void { + $status = $this->runCommand(array_values($args), $assocArgs); + + if ($status !== self::SUCCESS) { + WP_CLI::halt($status); + } + }, [ 'shortdesc' => $this->description(), 'synopsis' => $this->arguments(), ]); } protected function command(): string { - return trim($this->commandPrefix . ' ' . $this->subcommand()); + return trim($this->commandPrefix->value . ' ' . $this->subcommand()); } /** diff --git a/src/WPCli/README.md b/src/WPCli/README.md index d3f2413..862586f 100644 --- a/src/WPCli/README.md +++ b/src/WPCli/README.md @@ -3,7 +3,8 @@ > [!WARNING] > **This is a read-only repository!** For pull requests or issues, see [stellarwp/foundation](https://github.com/stellarwp/foundation). -Foundation helpers for building WP-CLI commands with the Foundation container. +Foundation WP-CLI provides a container-aware command base class and a shared +provider for registering application commands during WP-CLI bootstrap. ## Installation @@ -11,173 +12,11 @@ Foundation helpers for building WP-CLI commands with the Foundation container. composer require stellarwp/foundation-wpcli ``` -WP-CLI is expected to provide the `WP_CLI` and `WP_CLI_Command` runtime classes. This package includes `wp-cli/wp-cli` as a development dependency for tests and static analysis, but applications normally do not need to install it separately when running inside WP-CLI. +WP-CLI supplies its runtime classes when commands execute, so applications do +not normally need to install `wp-cli/wp-cli` separately. -Install `stellarwp/foundation-wpcli` as a normal dependency when the plugin ships WP-CLI commands. Install `stellarwp/foundation-cli` separately with `composer require --dev stellarwp/foundation-cli` only when developers need generators such as `make:wpcli-command`. +## Documentation -## Commands - -Extend `StellarWP\Foundation\WPCli\Command` for commands that should receive the Foundation container. - -## Generating Commands - -If the project also installs `stellarwp/foundation-cli` as a development dependency, scaffold a WP-CLI command class in a consuming WordPress project: - -```bash -vendor/bin/foundation make:wpcli-command Sync_Products_Command -``` - -If the consuming project has a Composer script named `foundation` that points to the installed Foundation binary, it can also run `composer run foundation -- make:wpcli-command Sync_Products_Command`. - -The generator reads the project's `autoload.psr-4` namespaces from `composer.json` and writes a Snake_Case command class under `Cli/Commands` inside the default PSR-4 root. When `--namespace` is passed, the output path is resolved from the matching PSR-4 root unless `--path` is also passed. - -For example, a project with this Composer autoload entry: - -```json -{ - "autoload": { - "psr-4": { - "Acme\\Plugin\\": "src" - } - } -} -``` - -will generate: - -```text -src/Cli/Commands/Sync_Products_Command.php -``` - -with namespace: - -```php -Acme\Plugin\Cli\Commands -``` - -The generated class extends `StellarWP\Foundation\WPCli\Command` and includes example positional, associative, and flag arguments using constants. - -When generated through `foundation-cli`, projects using Strauss with `extra.strauss.namespace_prefix` receive prefixed Foundation imports automatically. - -Available options: - -```bash -vendor/bin/foundation make:wpcli-command Sync_Products_Command --namespace="Acme\\Plugin\\Cli" --path=src/Cli --subcommand=sync-products --description="Sync products." --force -``` - -Project stub overrides live under: - -```text -foundation/stubs/wpcli/command.stub -``` - -When present, the override is used instead of the default stub from the `foundation-wpcli` package. - -Override stubs should use the same context-aware placeholders as the default stub when writing PHP literals. For example, use `{{ description_php }}` and `{{ subcommand_php }}` for values returned from PHP methods, and `{{ foundation_wpcli_command }}` for the Foundation command import so Strauss-prefixed projects keep working. - -```php -container. - - return self::SUCCESS; - } - - protected function subcommand(): string { - return {{ subcommand_php }}; - } - - protected function description(): string { - return {{ description_php }}; - } - - protected function arguments(): array { - return [ - [ - 'type' => self::FLAG, - 'name' => 'dry-run', - 'description' => 'Preview the sync without writing changes.', - 'optional' => true, - ], - ]; - } -} -``` - -## Provider Setup - -Applications should register their own provider so they control the command namespace and command list. - -Do not register `StellarWP\Foundation\Cli\CliProvider` in a WordPress plugin. That provider belongs to the developer-facing `foundation` console binary, not plugin runtime bootstrap. - -Generated command classes use Strauss-prefixed Foundation imports automatically when `extra.strauss.namespace_prefix` is configured. Handwritten provider code is still application code, so projects using Strauss with `update_call_sites=false` may need to use their prefixed Foundation namespace in the imports below. - -```php -> - */ - private const array COMMANDS = [ - Sync_Command::class, - ]; - - public function register(): void { - if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) { - return; - } - - $this->configureCommands(); - $this->registerTimestampedLogger(); - - add_action( 'cli_init', function (): void { - foreach ( self::COMMANDS as $commandClass ) { - $command = $this->container->get( $commandClass ); - - if ( $command instanceof Command ) { - $command->register(); - } - } - }, 0, 0 ); - } - - private function configureCommands(): void { - foreach ( self::COMMANDS as $commandClass ) { - $this->container->when( $commandClass ) - ->needs( '$commandPrefix' ) - ->give( self::COMMAND_PREFIX ); - } - } - - private function registerTimestampedLogger(): void { - $wpCliLogger = WP_CLI::get_logger(); - - if ( $wpCliLogger instanceof Regular ) { - WP_CLI::set_logger( new TimestampedLogger( $wpCliLogger ) ); - } - } -} -``` - -Use `cli_init` so commands are registered only during WP-CLI command bootstrap, after WordPress has loaded enough for plugin providers and hooks to be available. - -If your application does not use WordPress hooks during bootstrap, call the command registration loop at the point where WP-CLI is active and your container has been configured. +See the [Foundation WP-CLI documentation](https://foundation.nexcess.dev/components/wp-cli/) +for command generation, provider registration, prefixes, arguments, failure +behavior, and testing. diff --git a/src/WPCli/ValueObjects/CommandPrefix.php b/src/WPCli/ValueObjects/CommandPrefix.php new file mode 100644 index 0000000..988f413 --- /dev/null +++ b/src/WPCli/ValueObjects/CommandPrefix.php @@ -0,0 +1,22 @@ +value === '' || trim($this->value) !== $this->value) { + throw new InvalidArgumentException('The WP-CLI command prefix cannot be empty or contain surrounding whitespace.'); + } + } +} diff --git a/src/WPCli/WPCliProvider.php b/src/WPCli/WPCliProvider.php new file mode 100644 index 0000000..85d47e2 --- /dev/null +++ b/src/WPCli/WPCliProvider.php @@ -0,0 +1,73 @@ +foundationPrefix(); + $commandPrefix = $this->config->get('wpcli.command_prefix') + ?? $foundationPrefix; + + $this->container->mergeArrayVar(self::COMMANDS, []); + $this->container->when(CommandPrefix::class) + ->needs('$value') + ->give($commandPrefix); + $this->container->singleton(CommandPrefix::class); + + add_action('cli_init', function (): void { + $this->registerCommands(); + }, 0, 0); + } + + /** + * @throws UnexpectedValueException When the configured command list contains an invalid value. + */ + private function registerCommands(): void { + $commands = $this->container->get(self::COMMANDS); + + if (! is_iterable($commands)) { + throw new UnexpectedValueException(sprintf( + 'WP-CLI commands must be iterable; received %s.', + get_debug_type($commands) + )); + } + + $commands = is_array($commands) ? array_values($commands) : iterator_to_array($commands, false); + + foreach ($commands as $index => $command) { + if (! $command instanceof Command) { + throw new UnexpectedValueException(sprintf( + 'WP-CLI command at index %d must extend %s; received %s.', + $index, + Command::class, + get_debug_type($command) + )); + } + } + + foreach ($commands as $command) { + $command->register(); + } + } +} diff --git a/tests/CodeceptionSupport/.gitignore b/tests/CodeceptionSupport/.gitignore new file mode 100644 index 0000000..36e264c --- /dev/null +++ b/tests/CodeceptionSupport/.gitignore @@ -0,0 +1 @@ +_generated diff --git a/tests/CodeceptionSupport/FeatureTester.php b/tests/CodeceptionSupport/FeatureTester.php new file mode 100644 index 0000000..34ad51b --- /dev/null +++ b/tests/CodeceptionSupport/FeatureTester.php @@ -0,0 +1,28 @@ +assertFalse(class_exists('WP_CLI_Command', false)); + + $this->container->register(DatabaseProvider::class); + + $this->assertFalse(class_exists('WP_CLI_Command', false)); + } +} diff --git a/tests/Support/Fixtures/Container/Traits/FoundationPrefixProvider.php b/tests/Support/Fixtures/Container/Traits/FoundationPrefixProvider.php new file mode 100644 index 0000000..e6267b4 --- /dev/null +++ b/tests/Support/Fixtures/Container/Traits/FoundationPrefixProvider.php @@ -0,0 +1,18 @@ +foundationPrefix(); + } +} diff --git a/tests/Support/Fixtures/Database/DateTimePrecisionTable.php b/tests/Support/Fixtures/Database/DateTimePrecisionTable.php new file mode 100644 index 0000000..b70423f --- /dev/null +++ b/tests/Support/Fixtures/Database/DateTimePrecisionTable.php @@ -0,0 +1,28 @@ +table; + } + + public function definition(): TableDefinition { + return TableDefinition::for($this) + ->bigIncrements('id') + ->dateTime('occurred_at', 0); + } +} diff --git a/tests/Support/Fixtures/Database/FailingMigration.php b/tests/Support/Fixtures/Database/FailingMigration.php new file mode 100644 index 0000000..fff6556 --- /dev/null +++ b/tests/Support/Fixtures/Database/FailingMigration.php @@ -0,0 +1,37 @@ +id; + } + + public function up(Schema $schema): void { + if ($this->failUp) { + throw new RuntimeException('Migration up failed.'); + } + + $schema->execute('up:' . $this->id); + } + + public function down(Schema $schema): void { + if ($this->failDown) { + throw new RuntimeException('Migration down failed.'); + } + + $schema->execute('down:' . $this->id); + } +} diff --git a/tests/Support/Fixtures/Database/FakeDatabase.php b/tests/Support/Fixtures/Database/FakeDatabase.php new file mode 100644 index 0000000..007c980 --- /dev/null +++ b/tests/Support/Fixtures/Database/FakeDatabase.php @@ -0,0 +1,167 @@ + + */ + public array $executed = []; + + /** + * @var list + */ + public array $rowQueries = []; + + /** + * @var list + */ + public array $rowsQueries = []; + + /** + * @var list|callable(string, self): (array|null)|null> + */ + public array $rowResults = []; + + /** + * @var list>> + */ + public array $rowsResults = []; + + /** + * @var list + */ + public array $executeResults = []; + + public int $insertId = 1; + + public int $insertResult = 1; + + public function table(Table|string $table, ?string $alias = null): QueryBuilder { + return new QueryBuilder($this, $table, $alias); + } + + public function tableName(Table|string $table): string { + if ($table instanceof Table) { + return $table->name(); + } + + if (str_starts_with($table, 'wp_')) { + return $table; + } + + return 'wp_' . $table; + } + + public function tableExists(Table|string $table): bool { + return $this->row('SHOW TABLES LIKE %s', $this->escLike($this->tableName($table))) !== null; + } + + public function columnExists(Table|string $table, string $column): bool { + return $this->row('SHOW COLUMNS FROM %i LIKE %s', $this->tableName($table), $this->escLike($column)) !== null; + } + + public function indexExists(Table|string $table, string $index): bool { + return $this->row('SHOW INDEX FROM %i WHERE Key_name = %s', $this->tableName($table), $index) !== null; + } + + public function execute(string $sql, mixed ...$bindings): int { + $this->executed[] = $this->prepare($sql, ...$bindings); + + return array_shift($this->executeResults) ?? 1; + } + + /** + * @return array|null + */ + public function row(string $sql, mixed ...$bindings): ?array { + $query = $this->prepare($sql, ...$bindings); + $this->rowQueries[] = $query; + + $result = array_shift($this->rowResults); + + if (is_callable($result)) { + return $result($query, $this); + } + + return $result; + } + + /** + * @return list> + */ + public function rows(string $sql, mixed ...$bindings): array { + $this->rowsQueries[] = $this->prepare($sql, ...$bindings); + + return array_shift($this->rowsResults) ?? []; + } + + public function value(string $sql, mixed ...$bindings): mixed { + $row = $this->row($sql, ...$bindings); + + if ($row === null) { + return null; + } + + return reset($row); + } + + public function insert(Table|string $table, array $data): int { + $this->executed[] = 'INSERT ' . $this->tableName($table); + + return $this->insertResult; + } + + public function insertGetId(Table|string $table, array $data): int { + $this->insert($table, $data); + + return $this->insertId; + } + + public function update(Table|string $table, array $data, array $where): int { + $this->executed[] = 'UPDATE ' . $this->tableName($table); + + return array_shift($this->executeResults) ?? 1; + } + + public function delete(Table|string $table, array $where): int { + $this->executed[] = 'DELETE ' . $this->tableName($table); + + return array_shift($this->executeResults) ?? 1; + } + + public function prepare(string $sql, mixed ...$bindings): string { + $position = 0; + + return preg_replace_callback('/(? + */ + private array $records = []; + + private int $nextId = 1; + + /** + * @return array + */ + public function all(): array { + return $this->records; + } + + public function hasRun(string $migration): bool { + return isset($this->records[$migration]); + } + + public function recordRun(string $migration, int $batch): Record { + $record = new Record( + id: $this->nextId++, + migration: $migration, + batch: $batch, + ranAt: new DateTimeImmutable('2026-01-01 00:00:00') + ); + + $this->records[$migration] = $record; + + return $record; + } + + public function deleteRun(string $migration): bool { + if (! isset($this->records[$migration])) { + return false; + } + + unset($this->records[$migration]); + + return true; + } + + public function nextBatch(): int { + $latest = $this->latestBatch(); + + return $latest === null ? 1 : $latest + 1; + } + + public function latestBatch(): ?int { + $batches = array_map(static fn (Record $record): int => $record->batch, $this->records); + + return $batches === [] ? null : max($batches); + } + + /** + * @return list + */ + public function recordsForBatch(int $batch): array { + return array_values(array_filter( + $this->records, + static fn (Record $record): bool => $record->batch === $batch + )); + } +} diff --git a/tests/Support/Fixtures/Database/NoopMigration.php b/tests/Support/Fixtures/Database/NoopMigration.php new file mode 100644 index 0000000..e21b26a --- /dev/null +++ b/tests/Support/Fixtures/Database/NoopMigration.php @@ -0,0 +1,24 @@ +id; + } + + public function up(Schema $schema): void { + } + + public function down(Schema $schema): void { + } +} diff --git a/tests/Support/Fixtures/Database/RecordingSchema.php b/tests/Support/Fixtures/Database/RecordingSchema.php new file mode 100644 index 0000000..2981ce5 --- /dev/null +++ b/tests/Support/Fixtures/Database/RecordingSchema.php @@ -0,0 +1,66 @@ + + */ + public array $statements = []; + + /** + * @var array + */ + public array $tables = []; + + /** + * @var array> + */ + public array $indexes = []; + + public function createOrUpdate(Table $table): void { + $name = $table->name(); + $this->tables[$name] = true; + $this->statements[] = 'createOrUpdate:' . $name; + } + + public function execute(string $sql): void { + $this->statements[] = $sql; + } + + public function hasTable(Table|string $table): bool { + $name = $table instanceof Table ? $table->name() : $table; + + return $this->tables[$name] ?? false; + } + + public function hasIndex(Table|string $table, string $index): bool { + $name = $table instanceof Table ? $table->name() : $table; + + return $this->indexes[$name][$index] ?? false; + } + + public function dropIndex(Table|string $table, string $index): void { + $name = $table instanceof Table ? $table->name() : $table; + + unset($this->indexes[$name][$index]); + + $this->statements[] = sprintf('dropIndex:%s:%s', $name, $index); + } + + public function drop(Table|string $table): void { + $name = $table instanceof Table ? $table->name() : $table; + + unset($this->tables[$name]); + + $this->statements[] = 'drop:' . $name; + } + + public function quoteIdentifier(string $identifier): string { + return '`' . str_replace('`', '``', $identifier) . '`'; + } +} diff --git a/tests/Support/Fixtures/Database/RecordingSchemaExecutor.php b/tests/Support/Fixtures/Database/RecordingSchemaExecutor.php new file mode 100644 index 0000000..0a2755e --- /dev/null +++ b/tests/Support/Fixtures/Database/RecordingSchemaExecutor.php @@ -0,0 +1,17 @@ + + */ + public array $statements = []; + + public function execute(string $sql): void { + $this->statements[] = $sql; + } +} diff --git a/tests/Support/Fixtures/Database/SchemaReconciliationTable.php b/tests/Support/Fixtures/Database/SchemaReconciliationTable.php new file mode 100644 index 0000000..a347b6b --- /dev/null +++ b/tests/Support/Fixtures/Database/SchemaReconciliationTable.php @@ -0,0 +1,41 @@ +table; + } + + public function definition(): TableDefinition { + $definition = TableDefinition::for($this) + ->bigIncrements('id') + ->integer('attempts')->default($this->attemptsDefault) + ->dateTime('completed_at') + ->string('label')->default('') + ->column(new Column('ratio', 'decimal(10,2)', default: 1.25)) + ->column(new Column('enabled', 'bit', 1, default: true)); + + if ($this->completedAtNullable) { + $definition->column(new Column('completed_at', 'datetime', nullable: true)); + } + + return $definition; + } +} diff --git a/tests/Support/Fixtures/Database/TestMigration.php b/tests/Support/Fixtures/Database/TestMigration.php new file mode 100644 index 0000000..917f47f --- /dev/null +++ b/tests/Support/Fixtures/Database/TestMigration.php @@ -0,0 +1,26 @@ +id; + } + + public function up(Schema $schema): void { + $schema->execute('up:' . $this->id); + } + + public function down(Schema $schema): void { + $schema->execute('down:' . $this->id); + } +} diff --git a/tests/Support/Fixtures/Database/TestTable.php b/tests/Support/Fixtures/Database/TestTable.php new file mode 100644 index 0000000..585bd4f --- /dev/null +++ b/tests/Support/Fixtures/Database/TestTable.php @@ -0,0 +1,28 @@ +id; + } + + public function name(): string { + return $this->name; + } + + public function definition(): TableDefinition { + return TableDefinition::for($this) + ->bigIncrements('id'); + } +} diff --git a/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php new file mode 100644 index 0000000..fbaaa1b --- /dev/null +++ b/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php @@ -0,0 +1,91 @@ +bind(Container::class, $container); + $container->bind(ContainerInterface::class, $container); + $container->singleton(Dot::class, new Dot()); + + $database = new Database($wpdb); + $schema = new Schema($database, new Reconciler($database, new DbDelta())); + $migrationTableName = $wpdb->prefix . 'foundation_cli_migrations'; + $lockTableName = $wpdb->prefix . 'foundation_cli_locks'; + $exampleTable = $wpdb->prefix . 'foundation_cli_example'; + $migrationTable = new MigrationTable($migrationTableName); + $lockTable = new LockTable($lockTableName); + $repository = new Repository($database, $migrationTableName); + $lock = new DatabaseLock($database, $lockTableName); + $store = new Store($schema, $lock, $migrationTable, $lockTable); + + $migration = new class(new TestTable('foundation_cli_example', $exampleTable)) implements Migration { + public function __construct( + private readonly TestTable $table + ) { + } + + public function id(): string { + return '2026_06_23_000001_create_foundation_cli_example'; + } + + public function up(SchemaContract $schema): void { + $schema->createOrUpdate($this->table); + } + + public function down(SchemaContract $schema): void { + $schema->execute(sprintf( + 'DROP TABLE IF EXISTS %s', + $schema->quoteIdentifier($this->table->name()) + )); + } + }; + + $command = new Migrate( + $container, + new CommandPrefix('foundation'), + new Migrator( + new MigrationCollection([$migration]), + $repository, + $store + ) + ); + + $command->register(); +}); diff --git a/tests/Support/Fixtures/Identifier/Ulid/FixedEntropy.php b/tests/Support/Fixtures/Identifier/Ulid/FixedEntropy.php new file mode 100644 index 0000000..fbe8533 --- /dev/null +++ b/tests/Support/Fixtures/Identifier/Ulid/FixedEntropy.php @@ -0,0 +1,31 @@ +requestedLengths[] = $length; + + return $this->bytes; + } + + /** + * @return int[] + */ + public function requestedLengths(): array { + return $this->requestedLengths; + } +} diff --git a/tests/Support/Fixtures/Identifier/Ulid/FixedMillisecondClock.php b/tests/Support/Fixtures/Identifier/Ulid/FixedMillisecondClock.php new file mode 100644 index 0000000..d28e990 --- /dev/null +++ b/tests/Support/Fixtures/Identifier/Ulid/FixedMillisecondClock.php @@ -0,0 +1,17 @@ +milliseconds; + } +} diff --git a/tests/Support/Fixtures/Lock/MutableClock.php b/tests/Support/Fixtures/Lock/MutableClock.php new file mode 100644 index 0000000..8c32563 --- /dev/null +++ b/tests/Support/Fixtures/Lock/MutableClock.php @@ -0,0 +1,23 @@ +now; + } + + public function advance(int $seconds): void { + $this->now = $this->now->add(new DateInterval(sprintf('PT%dS', $seconds))); + } +} diff --git a/tests/Support/Fixtures/LockRedis/RecordingConnection.php b/tests/Support/Fixtures/LockRedis/RecordingConnection.php new file mode 100644 index 0000000..3da4286 --- /dev/null +++ b/tests/Support/Fixtures/LockRedis/RecordingConnection.php @@ -0,0 +1,41 @@ +, arguments: list}> + */ + public array $evaluateCalls = []; + + /** + * @var list + */ + public array $existsCalls = []; + + public function evaluate(string $script, array $keys, array $arguments): int { + $this->evaluateCalls[] = [ + 'script' => $script, + 'keys' => $keys, + 'arguments' => $arguments, + ]; + + return $this->evaluateResult; + } + + public function exists(string $key): bool { + $this->existsCalls[] = $key; + + return $this->existsResult; + } +} diff --git a/tests/Support/Fixtures/Shutdown/CallbackTerminable.php b/tests/Support/Fixtures/Shutdown/CallbackTerminable.php new file mode 100644 index 0000000..5ea7b66 --- /dev/null +++ b/tests/Support/Fixtures/Shutdown/CallbackTerminable.php @@ -0,0 +1,18 @@ +callback)(); + } +} diff --git a/tests/Support/Fixtures/Shutdown/finish-request-functions.php b/tests/Support/Fixtures/Shutdown/finish-request-functions.php new file mode 100644 index 0000000..9ef3f06 --- /dev/null +++ b/tests/Support/Fixtures/Shutdown/finish-request-functions.php @@ -0,0 +1,21 @@ +view->withDirectory($directory); + } + + public function render(string $name, array $data = []): string { + return $this->view->render($name, $data); + } +} diff --git a/tests/Support/Fixtures/View/JsonView.php b/tests/Support/Fixtures/View/JsonView.php new file mode 100644 index 0000000..83edfc4 --- /dev/null +++ b/tests/Support/Fixtures/View/JsonView.php @@ -0,0 +1,22 @@ + $name, + 'data' => $data, + ], JSON_THROW_ON_ERROR); + } +} diff --git a/tests/Support/Fixtures/WPCli/RecordingCommand.php b/tests/Support/Fixtures/WPCli/RecordingCommand.php new file mode 100644 index 0000000..55d30a3 --- /dev/null +++ b/tests/Support/Fixtures/WPCli/RecordingCommand.php @@ -0,0 +1,32 @@ +command(); + } + + protected function subcommand(): string { + return 'recording'; + } + + protected function description(): string { + return 'Recording command.'; + } + + protected function arguments(): array { + return []; + } +} diff --git a/tests/Unit/Cli/CliProviderTest.php b/tests/Unit/Cli/CliProviderTest.php index a802d90..b94fa6a 100644 --- a/tests/Unit/Cli/CliProviderTest.php +++ b/tests/Unit/Cli/CliProviderTest.php @@ -7,6 +7,9 @@ use StellarWP\ContainerContract\ContainerInterface; use StellarWP\Foundation\Cli\Application; use StellarWP\Foundation\Cli\CliProvider; +use StellarWP\Foundation\Cli\Commands\Make\Database\MigrationCommand; +use StellarWP\Foundation\Cli\Commands\Make\Database\ProviderCommand; +use StellarWP\Foundation\Cli\Commands\Make\Database\TableCommand; use StellarWP\Foundation\Cli\Commands\Make\WPCliCommand; use StellarWP\Foundation\Cli\Commands\Package\Contracts\PackageRepositoryCreator; use StellarWP\Foundation\Cli\Commands\Package\CreateCommand; @@ -33,6 +36,9 @@ public function test_it_registers_cli_services(): void { $this->assertInstanceOf(Application::class, $container->get(Application::class)); $this->assertInstanceOf(CreateCommand::class, $container->get(CreateCommand::class)); + $this->assertInstanceOf(MigrationCommand::class, $container->get(MigrationCommand::class)); + $this->assertInstanceOf(ProviderCommand::class, $container->get(ProviderCommand::class)); + $this->assertInstanceOf(TableCommand::class, $container->get(TableCommand::class)); $this->assertInstanceOf(WPCliCommand::class, $container->get(WPCliCommand::class)); $this->assertInstanceOf(PackageResolver::class, $container->get(PackageResolver::class)); $this->assertInstanceOf(PackageScaffolder::class, $container->get(PackageScaffolder::class)); @@ -43,6 +49,9 @@ public function test_it_registers_cli_services(): void { $this->assertInstanceOf(StubResolver::class, $container->get(StubResolver::class)); $this->assertInstanceOf(GitHubPackageRepositoryCreator::class, $container->get(PackageRepositoryCreator::class)); $this->assertTrue($container->get(Application::class)->has('package:create')); + $this->assertTrue($container->get(Application::class)->has('make:database-migration')); + $this->assertTrue($container->get(Application::class)->has('make:database-provider')); + $this->assertTrue($container->get(Application::class)->has('make:database-table')); $this->assertTrue($container->get(Application::class)->has('make:wpcli-command')); } } diff --git a/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php b/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php new file mode 100644 index 0000000..a2dfb15 --- /dev/null +++ b/tests/Unit/Cli/Commands/Make/DatabaseCommandTest.php @@ -0,0 +1,1569 @@ + + */ + private array $temporaryRoots = []; + + private string $tempDir; + + protected function setUp(): void { + parent::setUp(); + + $this->tempDir = $this->prepare_temp_dir('make-database-command'); + } + + protected function tearDown(): void { + foreach ($this->temporaryRoots as $temporaryRoot) { + $this->removeDirectory($temporaryRoot); + } + + parent::tearDown(); + } + + public function test_it_generates_a_database_table_from_project_autoload_defaults(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->tableCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'reports', + ]); + + $path = $root . '/src/Database/Tables/Reports_Table.php'; + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertFileExists($path); + $this->assertStringContainsString('Created: src/Database/Tables/Reports_Table.php', $tester->getDisplay()); + $this->assertStringContainsString('Add this table to a migration with Schema::createOrUpdate() and Schema::drop().', $tester->getDisplay()); + + $contents = (string) file_get_contents($path); + + $this->assertStringContainsString('namespace Acme\\Plugin\\Database\\Tables;', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Database;', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Table;', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Table\\TableDefinition;', $contents); + $this->assertStringContainsString('final readonly class Reports_Table implements Table {', $contents); + $this->assertStringContainsString("public const string ID = 'reports_table';", $contents); + $this->assertStringContainsString("public const string TABLE = 'reports';", $contents); + $this->assertStringContainsString('return $this->database->tableName( self::TABLE );', $contents); + $this->assertStringContainsString("->longText( 'payload' )", $contents); + } + + public function test_it_generates_a_database_migration_from_project_autoload_defaults(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->migrationCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + ]); + + $path = $root . '/src/Database/Migrations/Create_Reports_Table.php'; + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertFileExists($path); + $this->assertStringContainsString('Created: src/Database/Migrations/Create_Reports_Table.php', $tester->getDisplay()); + + $contents = (string) file_get_contents($path); + + $this->assertStringContainsString('namespace Acme\\Plugin\\Database\\Migrations;', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Migration;', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Schema;', $contents); + $this->assertStringContainsString('use Acme\\Plugin\\Database\\Tables\\Reports_Table;', $contents); + $this->assertStringContainsString('final readonly class Create_Reports_Table implements Migration {', $contents); + $this->assertStringContainsString("public const string ID = '2026_06_26_000001_create_reports_table';", $contents); + $this->assertStringContainsString('private Reports_Table $table', $contents); + $this->assertStringContainsString('$schema->createOrUpdate( $this->table );', $contents); + $this->assertStringContainsString('$schema->drop( $this->table );', $contents); + $this->assertStringNotContainsString('CreateTable', $contents); + } + + public function test_it_generates_a_generic_database_migration_for_non_table_names(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->migrationCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'bump-version', + '--id' => '2026_06_26_000003_bump_version', + ]); + + $contents = (string) file_get_contents($root . '/src/Database/Migrations/Bump_Version.php'); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringContainsString('final readonly class Bump_Version implements Migration {', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\Exceptions\\IrreversibleMigration;', $contents); + $this->assertStringContainsString("public const string ID = '2026_06_26_000003_bump_version';", $contents); + $this->assertStringContainsString('throw IrreversibleMigration::forMigration( self::ID );', $contents); + $this->assertStringNotContainsString('CreateTable', $contents); + $this->assertStringNotContainsString('Bump_Version_Table', $contents); + } + + public function test_it_generates_a_database_provider_from_project_autoload_defaults(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([]); + + $path = $root . '/src/Database/Provider.php'; + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertFileExists($path); + $this->assertStringContainsString('Created: src/Database/Provider.php', $tester->getDisplay()); + + $contents = (string) file_get_contents($path); + + $this->assertStringContainsString('namespace Acme\\Plugin\\Database;', $contents); + $this->assertStringContainsString('use lucatume\\DI52\\Container as C;', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\Database\\DatabaseProvider;', $contents); + $this->assertStringContainsString('use StellarWP\\Foundation\\Container\\Contracts\\Provider as Service_Provider;', $contents); + $this->assertStringContainsString('final class Provider extends Service_Provider {', $contents); + $this->assertStringContainsString('$this->register_tables();', $contents); + $this->assertStringContainsString('$this->register_migrations();', $contents); + $this->assertStringContainsString('// foundation:database-tables', $contents); + $this->assertStringNotContainsString('// foundation:database-migrations', $contents); + } + + public function test_database_migrations_default_to_timestamped_ids(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->migrationCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'create-reports-table', + ]); + + $contents = (string) file_get_contents($root . '/src/Database/Migrations/Create_Reports_Table.php'); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertMatchesRegularExpression( + "/public const string ID = '\\d{4}_\\d{2}_\\d{2}_\\d{6}_create_reports_table';/", + $contents + ); + } + + public function test_database_migrations_cannot_overwrite_existing_files(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^2.0', + ], + ]); + $command = $this->migrationCommand($root); + $path = $root . '/src/Database/Migrations/Create_Reports_Table.php'; + + (new CommandTester($this->providerCommand($root)))->execute([]); + mkdir(dirname($path), 0777, true); + file_put_contents($path, 'existing migration'); + + $providerPath = $root . '/src/Database/Provider.php'; + $providerContents = (string) file_get_contents($providerPath); + $tester = new CommandTester($command); + $status = $tester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_27_000001_create_reports_table', + ]); + + $this->assertFalse($command->getDefinition()->hasOption('force')); + $this->assertSame(Command::FAILURE, $status); + $this->assertStringContainsString( + 'Migration already exists: src/Database/Migrations/Create_Reports_Table.php. Edit it directly or create a new migration.', + $tester->getDisplay() + ); + $this->assertSame('existing migration', (string) file_get_contents($path)); + $this->assertSame($providerContents, (string) file_get_contents($providerPath)); + } + + public function test_database_tables_cannot_overwrite_existing_files(): void { + $root = $this->temporaryProject(); + $command = $this->tableCommand($root); + $path = $root . '/src/Database/Tables/Reports_Table.php'; + + mkdir(dirname($path), 0777, true); + file_put_contents($path, 'existing table'); + + $tester = new CommandTester($command); + $status = $tester->execute(['name' => 'reports']); + + $this->assertFalse($command->getDefinition()->hasOption('force')); + $this->assertSame(Command::FAILURE, $status); + $this->assertStringContainsString('File already exists: src/Database/Tables/Reports_Table.php.', $tester->getDisplay()); + $this->assertSame('existing table', (string) file_get_contents($path)); + } + + public function test_database_generators_accept_generation_options(): void { + $root = $this->temporaryProject(); + + $tableTester = new CommandTester($this->tableCommand($root)); + $tableStatus = $tableTester->execute([ + 'name' => 'Audit_Log', + '--namespace' => 'Acme\\Plugin\\Storage', + '--path' => 'custom/tables', + '--id' => 'audit_log_storage', + '--table' => 'custom_audit_log', + ]); + + $migrationTester = new CommandTester($this->migrationCommand($root)); + $migrationStatus = $migrationTester->execute([ + 'name' => 'Create_Audit_Log_Table', + '--namespace' => 'Acme\\Plugin\\Storage\\Migrations', + '--path' => 'custom/migrations', + '--id' => '2026_06_26_000002_create_audit_log_table', + '--table-class' => 'Audit_Log', + '--table-namespace' => 'Acme\\Plugin\\Storage', + ]); + + $tableContents = (string) file_get_contents($root . '/custom/tables/Audit_Log_Table.php'); + $migrationContents = (string) file_get_contents($root . '/custom/migrations/Create_Audit_Log_Table.php'); + + $this->assertSame(Command::SUCCESS, $tableStatus); + $this->assertStringContainsString('namespace Acme\\Plugin\\Storage;', $tableContents); + $this->assertStringContainsString("public const string ID = 'audit_log_storage';", $tableContents); + $this->assertStringContainsString("public const string TABLE = 'custom_audit_log';", $tableContents); + $this->assertSame(Command::SUCCESS, $migrationStatus); + $this->assertStringContainsString('namespace Acme\\Plugin\\Storage\\Migrations;', $migrationContents); + $this->assertStringContainsString('use Acme\\Plugin\\Storage\\Audit_Log_Table;', $migrationContents); + $this->assertStringContainsString("public const string ID = '2026_06_26_000002_create_audit_log_table';", $migrationContents); + } + + public function test_database_provider_generator_accepts_generation_options(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'Database_Provider', + '--namespace' => 'Acme\\Plugin\\Storage', + '--path' => 'custom/providers', + ]); + + $contents = (string) file_get_contents($root . '/custom/providers/Database_Provider.php'); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringContainsString('namespace Acme\\Plugin\\Storage;', $contents); + $this->assertStringContainsString('final class Database_Provider extends Service_Provider {', $contents); + } + + public function test_database_provider_generator_accepts_an_absolute_output_path(): void { + $root = $this->temporaryProject(); + $outputRoot = $this->temporaryRoot('foundation-make-database-provider-output-'); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([ + '--namespace' => 'Acme\\External\\Database', + '--path' => $outputRoot, + ]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertFileExists($outputRoot . '/Provider.php'); + $this->assertStringContainsString('Created: ' . $outputRoot . '/Provider.php', $tester->getDisplay()); + $this->assertStringContainsString('namespace Acme\\External\\Database;', (string) file_get_contents($outputRoot . '/Provider.php')); + } + + public function test_table_and_migration_generators_update_the_conventional_database_provider_when_it_exists(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + (new CommandTester($this->providerCommand($root)))->execute([]); + + $tableTester = new CommandTester($this->tableCommand($root)); + $tableStatus = $tableTester->execute([ + 'name' => 'reports', + ]); + + $migrationTester = new CommandTester($this->migrationCommand($root)); + $migrationStatus = $migrationTester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + ]); + + $contents = (string) file_get_contents($root . '/src/Database/Provider.php'); + + $this->assertSame(Command::SUCCESS, $tableStatus); + $this->assertStringContainsString('Updated: src/Database/Provider.php', $tableTester->getDisplay()); + $this->assertSame(Command::SUCCESS, $migrationStatus); + $this->assertStringContainsString('Updated: src/Database/Provider.php', $migrationTester->getDisplay()); + $this->assertStringContainsString('use Acme\\Plugin\\Database\\Tables\\Reports_Table;', $contents); + $this->assertStringContainsString('use Acme\\Plugin\\Database\\Migrations\\Create_Reports_Table;', $contents); + $this->assertStringContainsString('$this->container->singleton(Reports_Table::class);', $contents); + $this->assertStringContainsString('$c->get(Create_Reports_Table::class),', $contents); + $this->assertStringContainsString("\t\t\$this->container->singleton(Reports_Table::class);\n\t\t// foundation:database-tables", $contents); + $this->assertStringContainsString("\t\t\t\$c->get(Create_Reports_Table::class),\n\t\t] );", $contents); + $this->assertStringNotContainsString('Array$this', $contents); + $this->assertStringNotContainsString('Array$c', $contents); + } + + public function test_database_migration_generator_appends_to_existing_provider_migrations_in_order(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + (new CommandTester($this->providerCommand($root)))->execute([]); + + (new CommandTester($this->migrationCommand($root)))->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + ]); + (new CommandTester($this->migrationCommand($root)))->execute([ + 'name' => 'create-orders-table', + '--id' => '2026_06_26_000002_create_orders_table', + ]); + + $contents = (string) file_get_contents($root . '/src/Database/Provider.php'); + + $reportsOffset = strpos($contents, '$c->get(Create_Reports_Table::class),'); + $ordersOffset = strpos($contents, '$c->get(Create_Orders_Table::class),'); + + $this->assertIsInt($reportsOffset); + $this->assertIsInt($ordersOffset); + $this->assertGreaterThan($reportsOffset, $ordersOffset); + } + + public function test_table_and_migration_generators_update_an_explicit_database_provider_when_it_exists(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + (new CommandTester($this->providerCommand($root)))->execute([ + '--path' => 'custom/providers', + ]); + + $tableTester = new CommandTester($this->tableCommand($root)); + $tableStatus = $tableTester->execute([ + 'name' => 'reports', + '--provider' => 'custom/providers/Provider.php', + ]); + + $migrationTester = new CommandTester($this->migrationCommand($root)); + $migrationStatus = $migrationTester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + '--provider' => 'custom/providers/Provider.php', + ]); + + $contents = (string) file_get_contents($root . '/custom/providers/Provider.php'); + + $this->assertSame(Command::SUCCESS, $tableStatus); + $this->assertStringContainsString('Updated: custom/providers/Provider.php', $tableTester->getDisplay()); + $this->assertSame(Command::SUCCESS, $migrationStatus); + $this->assertStringContainsString('Updated: custom/providers/Provider.php', $migrationTester->getDisplay()); + $this->assertStringContainsString('$this->container->singleton(Reports_Table::class);', $contents); + $this->assertStringContainsString('$c->get(Create_Reports_Table::class),', $contents); + } + + public function test_explicit_database_provider_update_fails_when_the_provider_has_no_markers(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + mkdir($root . '/custom/providers', 0777, true); + file_put_contents($root . '/custom/providers/Provider.php', 'tableCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'reports', + '--provider' => 'custom/providers/Provider.php', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('file does not contain the generated database provider markers', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); + } + + public function test_explicit_database_provider_updates_fail_before_writing_when_the_provider_is_not_writable(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + (new CommandTester($this->providerCommand($root)))->execute([]); + + $providerPath = $root . '/src/Database/Provider.php'; + chmod($providerPath, 0444); + + $tableTester = new CommandTester($this->tableCommand($root)); + $tableStatus = $tableTester->execute([ + 'name' => 'reports', + '--provider' => 'src/Database/Provider.php', + ]); + + $migrationTester = new CommandTester($this->migrationCommand($root)); + $migrationStatus = $migrationTester->execute([ + 'name' => 'create-reports-table', + '--provider' => 'src/Database/Provider.php', + ]); + + $this->assertSame(Command::FAILURE, $tableStatus); + $this->assertSame(Command::FAILURE, $migrationStatus); + $this->assertStringContainsString('file is not writable', $tableTester->getDisplay()); + $this->assertStringContainsString('file is not writable', $migrationTester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); + $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Create_Reports_Table.php'); + } + + public function test_explicit_database_provider_migration_update_fails_when_the_provider_has_no_migration_anchor(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + mkdir($root . '/custom/providers', 0777, true); + file_put_contents($root . '/custom/providers/Provider.php', <<<'PHP' +migrationCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + '--provider' => 'custom/providers/Provider.php', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('file does not contain a generated database provider registration point', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Create_Reports_Table.php'); + } + + public function test_table_generator_warns_when_the_conventional_provider_cannot_be_updated(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + mkdir($root . '/src/Database', 0777, true); + file_put_contents( + $root . '/src/Database/Provider.php', + file_get_contents($this->data_dir('cli/generation/php-source-editor/database-provider-without-registration-points.stub')) + ); + + $tester = new CommandTester($this->tableCommand($root)); + $statusCode = $tester->execute(['name' => 'reports']); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertFileExists($root . '/src/Database/Tables/Reports_Table.php'); + $this->assertStringContainsString('Provider not updated: src/Database/Provider.php', $tester->getDisplay()); + $this->assertStringContainsString('Register Reports_Table manually.', $tester->getDisplay()); + } + + public function test_migration_generator_warns_when_the_conventional_provider_cannot_be_updated(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + mkdir($root . '/src/Database', 0777, true); + file_put_contents( + $root . '/src/Database/Provider.php', + file_get_contents($this->data_dir('cli/generation/php-source-editor/database-provider-without-registration-points.stub')) + ); + + $tester = new CommandTester($this->migrationCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + ]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertFileExists($root . '/src/Database/Migrations/Create_Reports_Table.php'); + $this->assertStringContainsString('Provider not updated: src/Database/Provider.php', $tester->getDisplay()); + $this->assertStringContainsString('Register Create_Reports_Table manually.', $tester->getDisplay()); + } + + public function test_database_provider_migration_update_preserves_legacy_migration_marker_position(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ + // foundation:database-migrations + ] ); + } +} +PHP); + + $status = $this->providerUpdater()->addMigration( + providerPath: $providerPath, + class: 'Create_Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Migrations' + ); + + $contents = (string) file_get_contents($providerPath); + + $this->assertSame(ProviderRegistrationEditor::UPDATED, $status); + $this->assertStringContainsString('use Acme\\Plugin\\Database\\Migrations\\Create_Reports_Table;', $contents); + $this->assertStringContainsString("\t\t\t\$c->get(Create_Reports_Table::class),\n\t\t\t// foundation:database-migrations", $contents); + } + + public function test_database_provider_migration_update_supports_direct_array_registrations(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, [ + ] ); + } +} +PHP); + + $status = $this->providerUpdater()->addMigration( + providerPath: $providerPath, + class: 'Create_Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Migrations' + ); + + $contents = (string) file_get_contents($providerPath); + + $this->assertSame(ProviderRegistrationEditor::UPDATED, $status); + $this->assertStringContainsString('$this->container->get(Create_Reports_Table::class),', $contents); + } + + public function test_database_provider_migration_update_supports_closure_callbacks_returning_arrays(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static function ( C $c ): array { + return [ + ]; + } ); + } +} +PHP); + + $status = $this->providerUpdater()->addMigration( + providerPath: $providerPath, + class: 'Create_Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Migrations' + ); + + $contents = (string) file_get_contents($providerPath); + + $this->assertSame(ProviderRegistrationEditor::UPDATED, $status); + $this->assertStringContainsString('use Acme\\Plugin\\Database\\Migrations\\Create_Reports_Table;', $contents); + $this->assertStringContainsString("\t\t\t\t\$c->get(Create_Reports_Table::class),\n\t\t\t];", $contents); + } + + public function test_database_provider_migration_update_uses_the_callback_parameter_name(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $container ): array => [ + ] ); + } +} +PHP); + + $status = $this->providerUpdater()->addMigration( + providerPath: $providerPath, + class: 'Create_Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Migrations' + ); + + $contents = (string) file_get_contents($providerPath); + + $this->assertSame(ProviderRegistrationEditor::UPDATED, $status); + $this->assertStringContainsString('$container->get(Create_Reports_Table::class),', $contents); + } + + public function test_explicit_database_provider_migration_update_fails_before_writing_when_the_array_cannot_be_safely_edited(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + mkdir($root . '/custom/providers', 0777, true); + file_put_contents($root . '/custom/providers/Provider.php', <<<'PHP' +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [] ); + } +} +PHP); + + $tester = new CommandTester($this->migrationCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + '--provider' => 'custom/providers/Provider.php', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('file does not contain a generated database provider registration point', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Create_Reports_Table.php'); + } + + public function test_explicit_database_provider_migration_update_fails_when_the_callback_has_no_container_parameter(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + mkdir($root . '/custom/providers', 0777, true); + file_put_contents($root . '/custom/providers/Provider.php', <<<'PHP' +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn (): array => [ + ] ); + } +} +PHP); + + $tester = new CommandTester($this->migrationCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + '--provider' => 'custom/providers/Provider.php', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('file does not contain a generated database provider registration point', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Create_Reports_Table.php'); + } + + public function test_database_provider_migration_update_ignores_unrelated_database_provider_imports(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ + ] ); + } +} +PHP); + + $status = $this->providerUpdater()->addMigration( + providerPath: $providerPath, + class: 'Create_Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Migrations' + ); + + $this->assertSame(ProviderRegistrationEditor::MISSING_ANCHOR, $status); + $this->assertStringNotContainsString('Create_Reports_Table', (string) file_get_contents($providerPath)); + } + + public function test_explicit_database_provider_update_fails_when_the_provider_cannot_be_parsed(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + mkdir($root . '/custom/providers', 0777, true); + file_put_contents($root . '/custom/providers/Provider.php', 'tableCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'reports', + '--provider' => 'custom/providers/Provider.php', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('file could not be parsed as PHP', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); + } + + public function test_database_provider_updates_ignore_marker_text_that_is_not_on_a_marker_line(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + (new CommandTester($this->providerCommand($root)))->execute([]); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, str_replace( + 'private function register_tables(): void {', + "/**\n\t * Example text: // foundation:database-tables\n\t */\n\tprivate function register_tables(): void {", + (string) file_get_contents($providerPath) + )); + + $tester = new CommandTester($this->tableCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'reports', + ]); + + $contents = (string) file_get_contents($providerPath); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertSame(1, substr_count($contents, '$this->container->singleton(Reports_Table::class);')); + $this->assertStringContainsString('Example text: // foundation:database-tables', $contents); + } + + public function test_database_provider_updater_adds_import_after_namespace_when_no_imports_exist(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +providerUpdater()->addTable( + providerPath: $providerPath, + class: 'Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Tables' + ); + + $contents = (string) file_get_contents($providerPath); + + $this->assertSame(ProviderRegistrationEditor::UPDATED, $status); + $this->assertStringContainsString("namespace Acme\\Plugin\\Database;\n\nuse Acme\\Plugin\\Database\\Tables\\Reports_Table;\n\nfinal class Provider", $contents); + $this->assertStringContainsString("\t\t\$this->container->singleton(Reports_Table::class);\n\t\t// foundation:database-tables", $contents); + $this->assertStringNotContainsString('Array$this', $contents); + } + + public function test_database_provider_updater_adds_import_when_same_class_uses_a_different_alias(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +providerUpdater()->addTable( + providerPath: $providerPath, + class: 'Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Tables' + ); + + $contents = (string) file_get_contents($providerPath); + + $this->assertSame(ProviderRegistrationEditor::UPDATED, $status); + $this->assertStringContainsString('use Acme\\Plugin\\Database\\Tables\\Reports_Table as Existing_Reports_Table;', $contents); + $this->assertStringContainsString('use Acme\\Plugin\\Database\\Tables\\Reports_Table;', $contents); + $this->assertStringContainsString("\t\t\$this->container->singleton(Reports_Table::class);\n\t\t// foundation:database-tables", $contents); + } + + public function test_database_provider_updater_preserves_inline_comments_when_adding_imports(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +providerUpdater()->addTable( + providerPath: $providerPath, + class: 'Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Tables' + ); + + $contents = (string) file_get_contents($providerPath); + + $this->assertSame(ProviderRegistrationEditor::UPDATED, $status); + $this->assertStringContainsString("use Acme\\Plugin\\Database\\Existing_Table; // keep this comment here\nuse Acme\\Plugin\\Database\\Tables\\Reports_Table;", $contents); + } + + public function test_database_provider_updater_ignores_marker_text_inside_non_marker_line_comments(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +providerUpdater()->addTable( + providerPath: $providerPath, + class: 'Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Tables' + ); + + $this->assertSame(ProviderRegistrationEditor::MISSING_MARKER, $status); + $this->assertSame(0, substr_count((string) file_get_contents($providerPath), '$this->container->singleton(Reports_Table::class);')); + } + + public function test_database_provider_updater_is_idempotent_with_grouped_imports(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, <<<'PHP' +container->singleton(Reports_Table::class); + // foundation:database-tables + } +} +PHP); + + $contents = (string) file_get_contents($providerPath); + $status = $this->providerUpdater()->addTable( + providerPath: $providerPath, + class: 'Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Tables' + ); + + $this->assertSame(ProviderRegistrationEditor::ALREADY_REGISTERED, $status); + $this->assertSame($contents, (string) file_get_contents($providerPath)); + } + + public function test_database_provider_updater_is_idempotent_after_wordpress_formatting(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + $contents = (string) file_get_contents($this->data_dir('cli/generation/php-source-editor/formatted-database-provider.stub')); + file_put_contents($providerPath, $contents); + + $tableStatus = $this->providerUpdater()->addTable( + providerPath: $providerPath, + class: 'Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Tables' + ); + $migrationStatus = $this->providerUpdater()->addMigration( + providerPath: $providerPath, + class: 'Create_Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Migrations' + ); + + $this->assertSame(ProviderRegistrationEditor::ALREADY_REGISTERED, $tableStatus); + $this->assertSame(ProviderRegistrationEditor::ALREADY_REGISTERED, $migrationStatus); + $this->assertSame($contents, (string) file_get_contents($providerPath)); + } + + public function test_database_provider_updater_is_idempotent_for_namespace_relative_registrations(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/src/Database', 0777, true); + + $providerPath = $root . '/src/Database/Provider.php'; + $contents = (string) file_get_contents($this->data_dir('cli/generation/php-source-editor/namespace-relative-registrations.stub')); + file_put_contents($providerPath, $contents); + + $tableStatus = $this->providerUpdater()->addTable( + providerPath: $providerPath, + class: 'Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Tables' + ); + $migrationStatus = $this->providerUpdater()->addMigration( + providerPath: $providerPath, + class: 'Create_Reports_Table', + classNamespace: 'Acme\\Plugin\\Database\\Migrations' + ); + + $this->assertSame(ProviderRegistrationEditor::ALREADY_REGISTERED, $tableStatus); + $this->assertSame(ProviderRegistrationEditor::ALREADY_REGISTERED, $migrationStatus); + $this->assertSame($contents, (string) file_get_contents($providerPath)); + } + + public function test_explicit_database_provider_update_fails_on_import_short_name_collisions(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + (new CommandTester($this->providerCommand($root)))->execute([]); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, str_replace( + 'use StellarWP\\Foundation\\Database\\DatabaseProvider;', + "use Acme\\Other\\reports_table;\nuse StellarWP\\Foundation\\Database\\DatabaseProvider;", + (string) file_get_contents($providerPath) + )); + + $tester = new CommandTester($this->tableCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'reports', + '--provider' => 'src/Database/Provider.php', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('another class declaration or import uses the same short class name', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); + } + + public function test_explicit_database_provider_update_fails_on_grouped_import_short_name_collisions(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + (new CommandTester($this->providerCommand($root)))->execute([]); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, str_replace( + 'use StellarWP\\Foundation\\Database\\DatabaseProvider;', + "use Acme\\Other\\{Reports as Reports_Table};\nuse StellarWP\\Foundation\\Database\\DatabaseProvider;", + (string) file_get_contents($providerPath) + )); + + $tester = new CommandTester($this->tableCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'reports', + '--provider' => 'src/Database/Provider.php', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('another class declaration or import uses the same short class name', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); + } + + public function test_explicit_database_provider_update_fails_on_aliased_import_short_name_collisions(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + (new CommandTester($this->providerCommand($root)))->execute([]); + + $providerPath = $root . '/src/Database/Provider.php'; + file_put_contents($providerPath, str_replace( + 'use StellarWP\\Foundation\\Database\\DatabaseProvider;', + "use Acme\\Other\\Reports as Reports_Table;\nuse StellarWP\\Foundation\\Database\\DatabaseProvider;", + (string) file_get_contents($providerPath) + )); + + $tester = new CommandTester($this->tableCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'reports', + '--provider' => 'src/Database/Provider.php', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('another class declaration or import uses the same short class name', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); + } + + public function test_explicit_database_provider_update_fails_when_the_generated_class_matches_the_provider_class(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + + (new CommandTester($this->providerCommand($root)))->execute([]); + + $providerPath = $root . '/src/Database/Provider.php'; + $providerContents = (string) file_get_contents($providerPath); + $tester = new CommandTester($this->migrationCommand($root)); + $statusCode = $tester->execute([ + 'name' => 'Provider', + '--provider' => 'src/Database/Provider.php', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('another class declaration or import uses the same short class name', $tester->getDisplay()); + $this->assertSame($providerContents, (string) file_get_contents($providerPath)); + $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Provider.php'); + } + + public function test_database_table_generator_accepts_an_absolute_output_path(): void { + $root = $this->temporaryProject(); + $outputRoot = $this->temporaryRoot('foundation-make-database-output-'); + $tester = new CommandTester($this->tableCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'reports', + '--path' => $outputRoot, + ]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertFileExists($outputRoot . '/Reports_Table.php'); + $this->assertStringContainsString('Created: ' . $outputRoot . '/Reports_Table.php', $tester->getDisplay()); + } + + public function test_database_generators_use_strauss_namespace_prefix_for_foundation_imports(): void { + $root = $this->temporaryProject([ + 'extra' => [ + 'strauss' => [ + 'namespace_prefix' => 'Acme\\Product\\', + ], + ], + ]); + + $tableTester = new CommandTester($this->tableCommand($root)); + $tableTester->execute([ + 'name' => 'reports', + ]); + + $migrationTester = new CommandTester($this->migrationCommand($root)); + $migrationTester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + ]); + + $tableContents = (string) file_get_contents($root . '/src/Database/Tables/Reports_Table.php'); + $migrationContents = (string) file_get_contents($root . '/src/Database/Migrations/Create_Reports_Table.php'); + + $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Contracts\\Database;', $tableContents); + $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\Contracts\\Migration;', $migrationContents); + $this->assertStringNotContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Database;', $tableContents); + $this->assertStringNotContainsString('use StellarWP\\Foundation\\Database\\Contracts\\Migration;', $migrationContents); + } + + public function test_database_provider_generator_uses_strauss_namespace_prefix_for_foundation_imports(): void { + $root = $this->temporaryProject([ + 'extra' => [ + 'strauss' => [ + 'namespace_prefix' => 'Acme\\Product\\', + ], + ], + ]); + + $statusCode = (new CommandTester($this->providerCommand($root)))->execute([]); + + $contents = (string) file_get_contents($root . '/src/Database/Provider.php'); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Database\\DatabaseProvider;', $contents); + $this->assertStringContainsString('use Acme\\Product\\StellarWP\\Foundation\\Container\\Contracts\\Provider as Service_Provider;', $contents); + $this->assertStringNotContainsString('use StellarWP\\Foundation\\Database\\DatabaseProvider;', $contents); + } + + public function test_database_generators_use_project_stub_overrides(): void { + $root = $this->temporaryProject(); + + mkdir($root . '/foundation/stubs/database', 0777, true); + file_put_contents($root . '/foundation/stubs/database/table.stub', 'tableCommand($root)))->execute([ + 'name' => 'reports', + ]); + (new CommandTester($this->migrationCommand($root)))->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + ]); + (new CommandTester($this->migrationCommand($root)))->execute([ + 'name' => 'bump-version', + '--id' => '2026_06_26_000003_bump_version', + ]); + (new CommandTester($this->providerCommand($root)))->execute([]); + + $this->assertStringContainsString( + 'Generated table Reports_Table in Acme\\Plugin\\Database\\Tables', + (string) file_get_contents($root . '/src/Database/Tables/Reports_Table.php') + ); + $this->assertStringContainsString( + 'Generated migration Create_Reports_Table with Reports_Table', + (string) file_get_contents($root . '/src/Database/Migrations/Create_Reports_Table.php') + ); + $this->assertStringContainsString( + 'Generated migration Bump_Version', + (string) file_get_contents($root . '/src/Database/Migrations/Bump_Version.php') + ); + $this->assertStringContainsString( + 'Generated provider Provider in Acme\\Plugin\\Database', + (string) file_get_contents($root . '/src/Database/Provider.php') + ); + } + + public function test_database_generators_warn_when_the_runtime_dependency_is_missing_from_production_requirements(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->tableCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'reports', + ]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringContainsString('Runtime dependency missing:', $tester->getDisplay()); + $this->assertStringContainsString('composer require stellarwp/foundation-database', $tester->getDisplay()); + } + + public function test_database_provider_generator_warns_when_the_runtime_dependency_is_missing_from_production_requirements(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringContainsString('Runtime dependency missing:', $tester->getDisplay()); + $this->assertStringContainsString('composer require stellarwp/foundation-database', $tester->getDisplay()); + } + + public function test_database_provider_generator_warns_when_the_runtime_dependency_is_only_a_development_dependency(): void { + $root = $this->temporaryProject([ + 'require-dev' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringContainsString('Runtime dependency missing:', $tester->getDisplay()); + $this->assertStringContainsString('only in require-dev', $tester->getDisplay()); + } + + public function test_database_provider_generator_does_not_warn_when_the_runtime_dependency_is_in_production_requirements(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringNotContainsString('Runtime dependency missing:', $tester->getDisplay()); + } + + public function test_database_provider_generator_does_not_warn_when_the_aggregate_runtime_dependency_is_in_production_requirements(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation' => '^1.2', + ], + ]); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringNotContainsString('Runtime dependency missing:', $tester->getDisplay()); + } + + public function test_database_generators_warn_when_the_runtime_dependency_is_only_a_development_dependency(): void { + $root = $this->temporaryProject([ + 'require-dev' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + $tester = new CommandTester($this->migrationCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + ]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringContainsString('Runtime dependency missing:', $tester->getDisplay()); + $this->assertStringContainsString('only in require-dev', $tester->getDisplay()); + } + + public function test_database_generators_do_not_warn_when_the_runtime_dependency_is_in_production_requirements(): void { + $root = $this->temporaryProject([ + 'require' => [ + 'stellarwp/foundation-database' => '^1.2', + ], + ]); + $tester = new CommandTester($this->migrationCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'create-reports-table', + '--id' => '2026_06_26_000001_create_reports_table', + ]); + + $this->assertSame(Command::SUCCESS, $statusCode); + $this->assertStringNotContainsString('Runtime dependency missing:', $tester->getDisplay()); + } + + public function test_database_generators_reject_invalid_namespaces_before_writing_files(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->tableCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'reports', + '--namespace' => 'Acme Plugin\\Database\\Tables', + '--path' => 'custom/tables', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('Namespace "Acme Plugin\\Database\\Tables" is not a valid PHP namespace.', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/custom/tables/Reports_Table.php'); + } + + public function test_database_provider_generator_rejects_invalid_namespaces_before_writing_files(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([ + '--namespace' => 'Acme Plugin\\Database', + '--path' => 'custom/providers', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('Namespace "Acme Plugin\\Database" is not a valid PHP namespace.', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/custom/providers/Provider.php'); + } + + public function test_database_generators_reject_namespaces_outside_the_autoload_root(): void { + $root = $this->temporaryProject(); + $tableTester = new CommandTester($this->tableCommand($root)); + + $tableStatus = $tableTester->execute([ + 'name' => 'reports', + '--namespace' => 'Acme\\PluginTools\\Database\\Tables', + ]); + + $migrationTester = new CommandTester($this->migrationCommand($root)); + $migrationStatus = $migrationTester->execute([ + 'name' => 'create-reports-table', + '--namespace' => 'Acme\\PluginTools\\Database\\Migrations', + ]); + + $this->assertSame(Command::FAILURE, $tableStatus); + $this->assertStringContainsString('Namespace "Acme\\PluginTools\\Database\\Tables" is outside the Composer PSR-4 namespaces in composer.json.', $tableTester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Tools/Database/Tables/Reports_Table.php'); + $this->assertSame(Command::FAILURE, $migrationStatus); + $this->assertStringContainsString('Namespace "Acme\\PluginTools\\Database\\Migrations" is outside the Composer PSR-4 namespaces in composer.json.', $migrationTester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Tools/Database/Migrations/Create_Reports_Table.php'); + } + + public function test_database_provider_generator_rejects_namespaces_outside_the_autoload_root(): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->providerCommand($root)); + + $statusCode = $tester->execute([ + '--namespace' => 'Acme\\PluginTools\\Database', + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('Namespace "Acme\\PluginTools\\Database" is outside the Composer PSR-4 namespaces in composer.json.', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Tools/Database/Provider.php'); + } + + /** + * @dataProvider invalidMigrationIdProvider + */ + #[DataProvider('invalidMigrationIdProvider')] + public function test_database_migration_generator_rejects_runtime_invalid_ids(string $id, string $message): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->migrationCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'bump-version', + '--id' => $id, + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString($message, $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Migrations/Bump_Version.php'); + } + + /** + * @dataProvider invalidMigrationIdProvider + */ + #[DataProvider('invalidMigrationIdProvider')] + public function test_database_table_generator_rejects_runtime_invalid_ids(string $id, string $message): void { + $root = $this->temporaryProject(); + $tester = new CommandTester($this->tableCommand($root)); + + $statusCode = $tester->execute([ + 'name' => 'reports', + '--id' => $id, + ]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString($message, $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Tables/Reports_Table.php'); + } + + /** + * @return iterable + */ + public static function invalidMigrationIdProvider(): iterable { + yield 'blank' => ['', 'cannot be blank']; + + yield 'padded' => [' padded ', 'surrounding whitespace']; + + yield 'integer-like' => ['123', 'integer-like']; + + yield 'over ledger limit' => [str_repeat('a', 192), 'cannot exceed 191 bytes']; + } + + public function test_database_migration_generator_rejects_an_overlong_generated_id(): void { + $root = $this->temporaryProject(); + $name = str_repeat('a', 180); + $tester = new CommandTester($this->migrationCommand($root)); + + $statusCode = $tester->execute(['name' => $name]); + + $this->assertSame(Command::FAILURE, $statusCode); + $this->assertStringContainsString('cannot exceed 191 bytes', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/src/Database/Migrations/' . ucfirst($name) . '.php'); + } + + public function test_database_generators_reject_class_names_that_conflict_with_stub_imports(): void { + $root = $this->temporaryProject(); + $cases = [ + [new CommandTester($this->tableCommand($root)), ['name' => 'Table'], 'src/Database/Tables/Table.php'], + [new CommandTester($this->migrationCommand($root)), ['name' => 'Migration'], 'src/Database/Migrations/Migration.php'], + [new CommandTester($this->providerCommand($root)), ['name' => 'C'], 'src/Database/C.php'], + ]; + + foreach ($cases as [$tester, $input, $relativePath]) { + $this->assertSame(Command::FAILURE, $tester->execute($input)); + $this->assertStringContainsString('declares or imports', $tester->getDisplay()); + $this->assertFileDoesNotExist($root . '/' . $relativePath); + } + } + + private function tableCommand(string $root): TableCommand { + return new TableCommand( + rootPath: $root, + autoloadResolver: new ComposerAutoloadResolver($root), + classNameResolver: new WordPressClassNameResolver(), + stubResolver: new StubResolver($root), + stubRenderer: new StubRenderer(), + fileWriter: $this->fileWriter(), + providerUpdater: $this->providerUpdater() + ); + } + + private function migrationCommand(string $root): MigrationCommand { + return new MigrationCommand( + rootPath: $root, + autoloadResolver: new ComposerAutoloadResolver($root), + classNameResolver: new WordPressClassNameResolver(), + stubResolver: new StubResolver($root), + stubRenderer: new StubRenderer(), + fileWriter: $this->fileWriter(), + providerUpdater: $this->providerUpdater() + ); + } + + private function providerCommand(string $root): ProviderCommand { + return new ProviderCommand( + rootPath: $root, + autoloadResolver: new ComposerAutoloadResolver($root), + classNameResolver: new WordPressClassNameResolver(), + stubResolver: new StubResolver($root), + stubRenderer: new StubRenderer(), + fileWriter: $this->fileWriter() + ); + } + + private function fileWriter(): GeneratedFileWriter { + return new GeneratedFileWriter(new PhpSourceEditor(new ParserFactory(), new Lexer())); + } + + private function providerUpdater(): ProviderRegistrationEditor { + return new ProviderRegistrationEditor( + sourceEditor: new PhpSourceEditor( + parserFactory: new ParserFactory(), + lexer: new Lexer() + ) + ); + } + + /** + * @param array $composer + */ + private function temporaryProject(array $composer = []): string { + $root = $this->temporaryRoot('foundation-make-database-test-'); + + file_put_contents($root . '/composer.json', json_encode(array_replace_recursive([ + 'autoload' => [ + 'psr-4' => [ + 'Acme\\Plugin\\' => 'src', + ], + ], + ], $composer), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return $root; + } + + private function temporaryRoot(string $prefix): string { + $root = $this->tempDir . '/' . $prefix . bin2hex(random_bytes(8)); + + if (! mkdir($root, 0777, true) && ! is_dir($root)) { + $this->fail(sprintf('Could not create temporary root "%s".', $root)); + } + + $this->temporaryRoots[] = $root; + + return $root; + } + + private function removeDirectory(string $directory): void { + if (! is_dir($directory)) { + return; + } + + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($files as $file) { + if ($file->isDir()) { + rmdir($file->getPathname()); + } else { + unlink($file->getPathname()); + } + } + + rmdir($directory); + } +} diff --git a/tests/Unit/Cli/Commands/Make/WPCliCommandTest.php b/tests/Unit/Cli/Commands/Make/WPCliCommandTest.php index 4d506f5..32f8c09 100644 --- a/tests/Unit/Cli/Commands/Make/WPCliCommandTest.php +++ b/tests/Unit/Cli/Commands/Make/WPCliCommandTest.php @@ -2,9 +2,12 @@ namespace StellarWP\Foundation\Tests\Unit\Cli\Commands\Make; +use PhpParser\Lexer; +use PhpParser\ParserFactory; use StellarWP\Foundation\Cli\Commands\Make\WPCliCommand; use StellarWP\Foundation\Cli\Generation\ComposerAutoloadResolver; use StellarWP\Foundation\Cli\Generation\GeneratedFileWriter; +use StellarWP\Foundation\Cli\Generation\Php\PhpSourceEditor; use StellarWP\Foundation\Cli\Generation\StubRenderer; use StellarWP\Foundation\Cli\Generation\StubResolver; use StellarWP\Foundation\Cli\Generation\WordPressClassNameResolver; @@ -49,6 +52,7 @@ public function test_it_generates_a_wpcli_command_from_project_autoload_defaults $this->assertSame(Command::SUCCESS, $statusCode); $this->assertFileExists($path); $this->assertStringContainsString('Created: src/Cli/Commands/Sync_Products_Command.php', $tester->getDisplay()); + $this->assertStringContainsString('Contribute this command to WPCliProvider::COMMANDS from its feature provider.', $tester->getDisplay()); $contents = (string) file_get_contents($path); @@ -299,14 +303,17 @@ public function test_it_uses_project_stub_overrides(): void { $root = $this->temporaryProject(); mkdir($root . '/foundation/stubs/wpcli', 0777, true); - file_put_contents($root . '/foundation/stubs/wpcli/command.stub', 'Generated {{ class }} in {{ namespace }}'); + file_put_contents( + $root . '/foundation/stubs/wpcli/command.stub', + 'command($root)); $tester->execute([ 'name' => 'Sync_Products', ]); - $this->assertSame( + $this->assertStringContainsString( 'Generated Sync_Products_Command in Acme\\Plugin\\Cli\\Commands', (string) file_get_contents($root . '/src/Cli/Commands/Sync_Products_Command.php') ); @@ -397,7 +404,7 @@ private function command(string $root): WPCliCommand { classNameResolver: new WordPressClassNameResolver(), stubResolver: new StubResolver($root), stubRenderer: new StubRenderer(), - fileWriter: new GeneratedFileWriter() + fileWriter: new GeneratedFileWriter(new PhpSourceEditor(new ParserFactory(), new Lexer())) ); } diff --git a/tests/Unit/Cli/Commands/Package/PackageScaffolderTest.php b/tests/Unit/Cli/Commands/Package/PackageScaffolderTest.php index c15bf99..3a88b57 100644 --- a/tests/Unit/Cli/Commands/Package/PackageScaffolderTest.php +++ b/tests/Unit/Cli/Commands/Package/PackageScaffolderTest.php @@ -46,6 +46,11 @@ public function test_it_creates_a_package_scaffold_with_the_default_package_name '.github/workflows/close-pull-request.yml', ], $scaffold->createdFiles); $this->assertFileExists($rootPath . '/src/WPCli/composer.json'); + $workflow = file_get_contents($rootPath . '/src/WPCli/.github/workflows/close-pull-request.yml'); + + $this->assertIsString($workflow); + $this->assertStringContainsString('types: [opened, reopened]', $workflow); + $this->assertStringContainsString("permissions:\n pull-requests: write", $workflow); $composer = $this->packageComposer($rootPath, 'WPCli'); diff --git a/tests/Unit/Cli/Generation/GeneratedFileWriterTest.php b/tests/Unit/Cli/Generation/GeneratedFileWriterTest.php new file mode 100644 index 0000000..e48e94a --- /dev/null +++ b/tests/Unit/Cli/Generation/GeneratedFileWriterTest.php @@ -0,0 +1,240 @@ +tempDir = $this->prepare_temp_dir('generated-file-writer'); + } + + public function test_it_writes_generated_files_to_nested_directories(): void { + $file = new GeneratedFile( + path: $this->tempDir . '/nested/Generated.php', + relativePath: 'nested/Generated.php', + contents: 'writer()->write($file); + + $this->assertFileExists($file->path); + $this->assertSame($file->contents, (string) file_get_contents($file->path)); + } + + public function test_it_rejects_invalid_php_before_creating_the_file(): void { + $file = new GeneratedFile( + path: $this->tempDir . '/Invalid.php', + relativePath: 'Invalid.php', + contents: 'expectException(RuntimeException::class); + $this->expectExceptionMessage('Generated file "Invalid.php" is not valid PHP.'); + + try { + $this->writer()->write($file); + } finally { + $this->assertFileDoesNotExist($file->path); + } + } + + public function test_it_rejects_case_insensitive_class_import_collisions_before_creating_the_file(): void { + $file = new GeneratedFile( + path: $this->tempDir . '/Migration.php', + relativePath: 'Migration.php', + contents: (string) file_get_contents($this->data_dir('cli/generation/generated-file-writer/class-import-collision.stub')) + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('declares or imports "migration" more than once'); + + try { + $this->writer()->write($file); + } finally { + $this->assertFileDoesNotExist($file->path); + } + } + + public function test_it_rejects_duplicate_identical_imports_before_creating_the_file(): void { + $file = new GeneratedFile( + path: $this->tempDir . '/Example.php', + relativePath: 'Example.php', + contents: (string) file_get_contents($this->data_dir('cli/generation/generated-file-writer/duplicate-import.stub')) + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('declares or imports "Migration" more than once'); + + try { + $this->writer()->write($file); + } finally { + $this->assertFileDoesNotExist($file->path); + } + } + + public function test_it_rejects_duplicate_class_declarations_before_creating_the_file(): void { + $file = new GeneratedFile( + path: $this->tempDir . '/Duplicate.php', + relativePath: 'Duplicate.php', + contents: (string) file_get_contents($this->data_dir('cli/generation/generated-file-writer/duplicate-class.stub')) + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('declares or imports "duplicate" more than once'); + + try { + $this->writer()->write($file); + } finally { + $this->assertFileDoesNotExist($file->path); + } + } + + public function test_it_refuses_to_overwrite_existing_files_without_force(): void { + $path = $this->tempDir . '/Generated.php'; + + file_put_contents($path, 'existing'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('File already exists: Generated.php.'); + + $this->writer()->write(new GeneratedFile( + path: $path, + relativePath: 'Generated.php', + contents: 'tempDir . '/Generated.php'; + + file_put_contents($path, 'existing'); + + $this->writer()->write(new GeneratedFile( + path: $path, + relativePath: 'Generated.php', + contents: 'assertSame('tempDir . '/Generated.php'; + + PHPMockery::mock('StellarWP\Foundation\Cli\Generation', 'fopen') + ->with($path, 'x') + ->once() + ->andReturn(false); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not write generated file "Generated.php".'); + + $this->writer()->write(new GeneratedFile( + path: $path, + relativePath: 'Generated.php', + contents: 'tempDir . '/Generated.php'; + $handle = fopen('php://temp', 'w+'); + + $this->assertIsResource($handle); + + PHPMockery::mock('StellarWP\Foundation\Cli\Generation', 'fopen') + ->with($path, 'x') + ->once() + ->andReturn($handle); + PHPMockery::mock('StellarWP\Foundation\Cli\Generation', 'fwrite') + ->with($handle, 'once() + ->andReturn(3); + PHPMockery::mock('StellarWP\Foundation\Cli\Generation', 'fclose') + ->with($handle) + ->once() + ->andReturn(true); + PHPMockery::mock('StellarWP\Foundation\Cli\Generation', 'unlink') + ->with($path) + ->once() + ->andReturn(true); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not write generated file "Generated.php".'); + + try { + $this->writer()->write(new GeneratedFile( + path: $path, + relativePath: 'Generated.php', + contents: 'tempDir . '/blocked'; + + file_put_contents($path, 'file'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage(sprintf('Could not create directory "%s/Generated.php".', $path)); + + set_error_handler(static fn (): bool => true); + + try { + $this->writer()->write(new GeneratedFile( + path: $path . '/Generated.php/File.php', + relativePath: 'blocked/Generated.php/File.php', + contents: 'tempDir . '/Generated.php'; + + mkdir($path); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not write generated file "Generated.php".'); + + set_error_handler(static fn (): bool => true); + + try { + $this->writer()->write(new GeneratedFile( + path: $path, + relativePath: 'Generated.php', + contents: 'editor(); + + $this->assertFalse($editor->canParse('assertNull($editor->addImport('assertFalse($editor->canInsertIntoMergeArrayVar( + 'assertNull($editor->insertIntoMergeArrayVar( + 'get(Generated::class),' + )); + } + + public function test_it_returns_existing_source_when_an_import_already_exists(): void { + $contents = $this->fixture('existing-import'); + + $this->assertSame($contents, $this->editor()->addImport($contents, 'Acme\\Generated')); + $this->assertTrue($this->editor()->hasImport($contents, 'acme\\generated')); + } + + public function test_it_detects_case_insensitive_import_collisions(): void { + $this->assertTrue($this->editor()->hasImportShortNameCollision( + $this->fixture('existing-import'), + 'generated', + 'Acme\\Other\\Generated' + )); + } + + public function test_it_returns_null_when_a_line_comment_cannot_be_found(): void { + $this->assertNull($this->editor()->insertBeforeLineComment( + 'container->singleton(Generated::class);' + )); + } + + public function test_it_adds_imports_to_files_without_namespaces_or_opening_tags(): void { + $editor = $this->editor(); + + $this->assertStringStartsWith( + "addImport('assertStringContainsString( + 'use Acme\\Generated;final class Provider {}', + (string) $editor->addImport('final class Provider {}', 'Acme\\Generated') + ); + } + + public function test_it_adds_imports_after_a_last_use_without_a_trailing_newline(): void { + $contents = <<<'PHP' +assertStringContainsString( + "use Acme\\Existing;\nuse Acme\\Generated;\nfinal class Provider", + (string) $this->editor()->addImport($contents, 'Acme\\Generated') + ); + } + + public function test_it_ignores_function_imports_when_resolving_class_imports(): void { + $this->assertFalse($this->editor()->hasImport($this->fixture('function-import'), 'Acme\\Generated')); + } + + public function test_it_rejects_merge_array_var_calls_that_are_not_foundation_database_migration_lists(): void { + $editor = $this->editor(); + $class = 'StellarWP\\Foundation\\Database\\DatabaseProvider'; + + $this->assertFalse($editor->canInsertIntoMergeArrayVar($this->fixture('not-container-merge-array-var'), $class, 'MIGRATIONS')); + $this->assertFalse($editor->canInsertIntoMergeArrayVar($this->fixture('wrong-first-argument-merge-array-var'), $class, 'MIGRATIONS')); + $this->assertFalse($editor->canInsertIntoMergeArrayVar($this->fixture('wrong-constant-merge-array-var'), $class, 'MIGRATIONS')); + } + + public function test_it_matches_fully_qualified_strauss_prefixed_database_provider_references(): void { + $updated = $this->editor()->insertIntoMergeArrayVar( + $this->fixture('strauss-prefixed-database-provider'), + 'StellarWP\\Foundation\\Database\\DatabaseProvider', + 'MIGRATIONS', + '$this->container->get(Generated::class),' + ); + + $this->assertStringContainsString('$this->container->get(Generated::class),', (string) $updated); + } + + public function test_it_rejects_merge_array_var_callbacks_that_do_not_expose_a_registration_list(): void { + $this->assertFalse($this->editor()->canInsertIntoMergeArrayVar( + $this->fixture('arrow-callback-without-registration-list'), + 'StellarWP\\Foundation\\Database\\DatabaseProvider', + 'MIGRATIONS' + )); + } + + public function test_it_rejects_merge_array_var_closures_without_container_parameters_or_array_returns(): void { + $editor = $this->editor(); + $class = 'StellarWP\\Foundation\\Database\\DatabaseProvider'; + + $this->assertFalse($editor->canInsertIntoMergeArrayVar($this->fixture('closure-without-container-parameter'), $class, 'MIGRATIONS')); + $this->assertFalse($editor->canInsertIntoMergeArrayVar($this->fixture('closure-without-array-return'), $class, 'MIGRATIONS')); + } + + public function test_it_uses_space_indentation_when_inserting_into_space_indented_arrays(): void { + $updated = $this->editor()->insertIntoMergeArrayVar( + $this->fixture('space-indented-registration-list'), + 'StellarWP\\Foundation\\Database\\DatabaseProvider', + 'MIGRATIONS', + '$this->container->get(Generated::class),' + ); + + $this->assertStringContainsString( + " \$this->container->get(Existing::class),\n \$this->container->get(Generated::class),", + (string) $updated + ); + } + + public function test_it_does_not_treat_an_unresolved_class_name_as_a_merge_array_registration(): void { + $editor = $this->editor(); + $class = 'StellarWP\\Foundation\\Database\\DatabaseProvider'; + $target = 'Acme\\Plugin\\Database\\Migrations\\Create_Reports_Table'; + + $this->assertFalse($editor->mergeArrayVarContainsClass( + $this->fixture('migration-class-reference-without-registration'), + $class, + 'MIGRATIONS', + $target + )); + $this->assertFalse($editor->mergeArrayVarContainsClass( + $this->fixture('nested-migration-registration'), + $class, + 'MIGRATIONS', + $target + )); + } + + public function test_it_resolves_namespace_relative_provider_registrations(): void { + $contents = $this->fixture('namespace-relative-registrations'); + + $this->assertTrue($this->editor()->hasContainerSingleton( + $contents, + 'Acme\\Plugin\\Database\\Tables\\Reports_Table' + )); + $this->assertTrue($this->editor()->mergeArrayVarContainsClass( + $contents, + 'StellarWP\\Foundation\\Database\\DatabaseProvider', + 'MIGRATIONS', + 'Acme\\Plugin\\Database\\Migrations\\Create_Reports_Table' + )); + } + + public function test_it_resolves_imported_namespace_prefixes(): void { + $contents = $this->fixture('imported-prefix-registrations'); + + $this->assertTrue($this->editor()->hasContainerSingleton( + $contents, + 'Acme\Plugin\Database\Tables\Reports_Table' + )); + $this->assertTrue($this->editor()->mergeArrayVarContainsClass( + $contents, + 'StellarWP\Foundation\Database\DatabaseProvider', + 'MIGRATIONS', + 'Acme\Plugin\Database\Migrations\Create_Reports_Table' + )); + } + + public function test_it_does_not_treat_imported_namespaces_as_local_references(): void { + $contents = $this->fixture('import-shadowed-registrations'); + + $this->assertFalse($this->editor()->hasContainerSingleton( + $contents, + 'Acme\Plugin\Database\Tables\Reports_Table' + )); + $this->assertFalse($this->editor()->mergeArrayVarContainsClass( + $contents, + 'StellarWP\Foundation\Database\DatabaseProvider', + 'MIGRATIONS', + 'Acme\Plugin\Database\Migrations\Create_Reports_Table' + )); + } + + public function test_it_uses_valid_later_merge_array_contributions(): void { + $contents = $this->fixture('multiple-migration-contributions'); + $editor = $this->editor(); + + $this->assertTrue($editor->canInsertIntoMergeArrayVar( + $contents, + 'StellarWP\Foundation\Database\DatabaseProvider', + 'MIGRATIONS' + )); + $this->assertTrue($editor->mergeArrayVarContainsClass( + $contents, + 'StellarWP\Foundation\Database\DatabaseProvider', + 'MIGRATIONS', + 'Acme\Plugin\Database\Migrations\Create_Reports_Table' + )); + $this->assertStringContainsString( + '$c->get(Generated::class),', + (string) $editor->insertIntoMergeArrayVar( + $contents, + 'StellarWP\Foundation\Database\DatabaseProvider', + 'MIGRATIONS', + '$c->get(Generated::class),' + ) + ); + } + + private function editor(): PhpSourceEditor { + return new PhpSourceEditor( + parserFactory: new ParserFactory(), + lexer: new Lexer() + ); + } + + private function fixture(string $name): string { + return (string) file_get_contents($this->data_dir('cli/generation/php-source-editor/' . $name . '.stub')); + } +} diff --git a/tests/Unit/Cli/Generation/WordPressClassNameResolverTest.php b/tests/Unit/Cli/Generation/WordPressClassNameResolverTest.php index 0270214..a18b11b 100644 --- a/tests/Unit/Cli/Generation/WordPressClassNameResolverTest.php +++ b/tests/Unit/Cli/Generation/WordPressClassNameResolverTest.php @@ -53,6 +53,30 @@ public function test_it_creates_a_description_from_a_command_class(): void { $this->assertSame('Sync products.', (new WordPressClassNameResolver())->description('Sync_Products_Command')); } + public function test_it_normalizes_generic_wordpress_class_names(): void { + $this->assertSame('Bump_Version', (new WordPressClassNameResolver())->className('bump-version')); + } + + public function test_it_normalizes_table_class_names(): void { + $this->assertSame('Reports_Table', (new WordPressClassNameResolver())->tableClass('reports')); + $this->assertSame('Reports_Table', (new WordPressClassNameResolver())->tableClass('Reports_Table')); + } + + public function test_it_creates_table_names_from_table_classes(): void { + $this->assertSame('reports', (new WordPressClassNameResolver())->tableName('Reports_Table')); + } + + public function test_it_uses_the_lowercase_class_name_when_a_table_name_has_no_words(): void { + $this->assertSame('@@@', (new WordPressClassNameResolver())->tableName('@@@')); + } + + public function test_it_creates_timestamped_migration_ids_from_migration_classes(): void { + $this->assertSame( + '2026_06_26_120000_create_reports_table', + (new WordPressClassNameResolver())->migrationId('Create_Reports_Table', new \DateTimeImmutable('2026-06-26 12:00:00')) + ); + } + public function test_it_fails_when_input_cannot_be_normalized_to_a_class_name(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Could not create a class name from "@@@".'); @@ -60,6 +84,13 @@ public function test_it_fails_when_input_cannot_be_normalized_to_a_class_name(): (new WordPressClassNameResolver())->commandClass('@@@'); } + public function test_it_fails_when_generic_class_input_cannot_be_normalized_to_a_class_name(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not create a class name from "@@@".'); + + (new WordPressClassNameResolver())->className('@@@'); + } + public function test_it_fails_when_the_generated_class_name_would_start_with_a_number(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Could not create a valid PHP class name from "2fa-sync".'); @@ -67,6 +98,13 @@ public function test_it_fails_when_the_generated_class_name_would_start_with_a_n (new WordPressClassNameResolver())->commandClass('2fa-sync'); } + public function test_it_fails_when_the_generic_class_name_would_start_with_a_number(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not create a valid PHP class name from "2fa-sync".'); + + (new WordPressClassNameResolver())->className('2fa-sync'); + } + public function test_it_fails_when_the_generated_class_name_would_conflict_with_the_base_command(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Could not create a command class named "Command" from "Command"'); @@ -74,6 +112,27 @@ public function test_it_fails_when_the_generated_class_name_would_conflict_with_ (new WordPressClassNameResolver())->commandClass('Command'); } + public function test_it_fails_when_table_input_cannot_be_normalized_to_a_class_name(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not create a table class name from "@@@".'); + + (new WordPressClassNameResolver())->tableClass('@@@'); + } + + public function test_it_fails_when_the_table_class_name_would_start_with_a_number(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not create a valid PHP class name from "2fa".'); + + (new WordPressClassNameResolver())->tableClass('2fa'); + } + + public function test_it_fails_when_migration_input_cannot_be_normalized_to_an_id(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not create a migration id from "@@@".'); + + (new WordPressClassNameResolver())->migrationId('@@@'); + } + public function test_it_uses_a_default_description_when_the_class_has_no_words(): void { $this->assertSame('Run the command.', (new WordPressClassNameResolver())->description('Command')); } diff --git a/tests/Unit/Container/ContainerAdapterTest.php b/tests/Unit/Container/ContainerAdapterTest.php index 6876066..3134829 100644 --- a/tests/Unit/Container/ContainerAdapterTest.php +++ b/tests/Unit/Container/ContainerAdapterTest.php @@ -25,6 +25,15 @@ public function test_it_returns_callbacks_from_the_wrapped_container(): void { $this->assertSame('value', $callback()); } + public function test_it_merges_array_bindings_on_the_wrapped_container(): void { + $adapter = new ContainerAdapter(new DI52Container()); + + $adapter->mergeArrayVar('values', ['first']); + $adapter->mergeArrayVar('values', static fn (): array => ['second']); + + $this->assertSame(['first', 'second'], $adapter->get('values')); + } + public function test_it_forwards_unknown_method_calls_to_the_wrapped_container(): void { $adapter = new ContainerAdapter(new DI52Container()); diff --git a/tests/Unit/Container/Traits/ResolvesFoundationPrefixTest.php b/tests/Unit/Container/Traits/ResolvesFoundationPrefixTest.php new file mode 100644 index 0000000..b99beee --- /dev/null +++ b/tests/Unit/Container/Traits/ResolvesFoundationPrefixTest.php @@ -0,0 +1,63 @@ +container, new Dot()); + + $this->assertSame('nx', $provider->configuredFoundationPrefix()); + } + + public function test_it_returns_the_default_for_an_empty_prefix(): void { + $provider = new FoundationPrefixProvider($this->container, new Dot([ + 'foundation' => [ + 'prefix' => '', + ], + ])); + + $this->assertSame('nx', $provider->configuredFoundationPrefix()); + } + + public function test_it_provides_the_configured_foundation_prefix(): void { + $provider = new FoundationPrefixProvider($this->container, new Dot([ + 'foundation' => [ + 'prefix' => 'your-plugin', + ], + ])); + + $this->assertSame('your-plugin', $provider->configuredFoundationPrefix()); + } + + public function test_it_rejects_an_invalid_foundation_prefix(): void { + $provider = new FoundationPrefixProvider($this->container, new Dot([ + 'foundation' => [ + 'prefix' => 'Your Plugin', + ], + ])); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('lowercase kebab-case'); + + $provider->configuredFoundationPrefix(); + } + + public function test_it_rejects_a_non_string_prefix(): void { + $provider = new FoundationPrefixProvider($this->container, new Dot([ + 'foundation' => [ + 'prefix' => ['your-plugin'], + ], + ])); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('lowercase kebab-case'); + + $provider->configuredFoundationPrefix(); + } +} diff --git a/tests/Unit/Database/Cli/MigrateTest.php b/tests/Unit/Database/Cli/MigrateTest.php new file mode 100644 index 0000000..55b65ce --- /dev/null +++ b/tests/Unit/Database/Cli/MigrateTest.php @@ -0,0 +1,235 @@ +loadWpCliUtilities(); + + $migrationTable = new MigrationTable('wp_nx_foundation_migrations'); + $repository = new InMemoryRepository(); + $schema = new RecordingSchema(); + $lock = new InMemoryLock(); + $store = new Store($schema, $lock, $migrationTable, new LockTable('wp_nx_foundation_locks')); + $command = new Migrate( + $this->container, + new CommandPrefix('foundation'), + new Migrator( + new MigrationCollection(), + $repository, + $store + ) + ); + + $command->register(); + + $deferredAdditions = WP_CLI::get_deferred_additions(); + + $this->assertArrayHasKey('foundation migrate', $deferredAdditions); + $this->assertSame('foundation', $deferredAdditions['foundation migrate']['parent']); + $this->assertSame('List and manage database migrations.', $deferredAdditions['foundation migrate']['args']['shortdesc']); + $this->assertSame([ + [ + 'type' => 'flag', + 'name' => 'run', + 'description' => 'Run pending migrations.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => 'flag', + 'name' => 'rollback', + 'description' => 'Rollback the latest migration batch.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => 'flag', + 'name' => 'refresh', + 'description' => 'Rollback and rerun all migrations.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => 'flag', + 'name' => 'drop-store', + 'description' => 'Drop only the migration ledger.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => 'flag', + 'name' => 'initialize', + 'description' => 'Initialize or reconcile Foundation migration storage.', + 'optional' => true, + 'default' => false, + ], + [ + 'type' => 'flag', + 'name' => 'yes', + 'description' => 'Skip confirmation prompts for destructive actions.', + 'optional' => true, + 'default' => false, + ], + ], $deferredAdditions['foundation migrate']['args']['synopsis']); + } + + public function test_it_initializes_database_storage_without_running_migrations(): void { + [$command, $repository, $schema] = $this->newCommand(); + + $this->assertSame(0, $command->runCommand([], ['initialize' => true])); + + $this->assertSame([], $repository->all()); + $this->assertSame([ + 'createOrUpdate:wp_nx_foundation_locks', + 'createOrUpdate:wp_nx_foundation_migrations', + ], $schema->statements); + } + + public function test_it_runs_pending_migrations(): void { + [$command, $repository, $schema] = $this->newCommand(); + $command->runCommand([], ['initialize' => true]); + $schema->statements = []; + + $this->assertSame(0, $command->runCommand([], ['run' => true])); + + $this->assertTrue($repository->hasRun('2026_06_23_000001_create_example')); + $this->assertSame([ + 'up:2026_06_23_000001_create_example', + ], $schema->statements); + } + + public function test_it_rolls_back_the_latest_migration_batch(): void { + [$command, $repository, $schema] = $this->newCommand(); + + $command->runCommand([], ['initialize' => true]); + $command->runCommand([], ['run' => true]); + $schema->statements = []; + + $this->assertSame(0, $command->runCommand([], ['rollback' => true])); + + $this->assertFalse($repository->hasRun('2026_06_23_000001_create_example')); + $this->assertContains('down:2026_06_23_000001_create_example', $schema->statements); + } + + public function test_it_refreshes_database_migrations(): void { + [$command, $repository, $schema] = $this->newCommand(); + + $command->runCommand([], ['initialize' => true]); + $command->runCommand([], ['run' => true]); + $schema->statements = []; + + $this->assertSame(0, $command->runCommand([], [ + 'refresh' => true, + 'yes' => true, + ])); + + $this->assertTrue($repository->hasRun('2026_06_23_000001_create_example')); + $this->assertContains('down:2026_06_23_000001_create_example', $schema->statements); + $this->assertContains('up:2026_06_23_000001_create_example', $schema->statements); + } + + public function test_it_drops_the_migration_store(): void { + [$command, , $schema] = $this->newCommand(); + + $command->runCommand([], ['initialize' => true]); + + $this->assertSame(0, $command->runCommand([], [ + 'drop-store' => true, + 'yes' => true, + ])); + + $this->assertSame(['wp_nx_foundation_locks' => true], $schema->tables); + $this->assertContains('drop:wp_nx_foundation_migrations', $schema->statements); + $this->assertNotContains('drop:wp_nx_foundation_locks', $schema->statements); + } + + public function test_it_shows_a_warning_when_status_tables_do_not_exist(): void { + [$command] = $this->newCommand(); + + $this->expectOutputRegex('/2026_06_23_000001_create_example\s+pending/'); + + $this->assertSame(0, $command->runCommand()); + } + + public function test_it_shows_migration_status_when_tables_exist(): void { + [$command] = $this->newCommand(); + + $command->runCommand([], ['initialize' => true]); + $command->runCommand([], ['run' => true]); + + $this->expectOutputRegex('/2026_06_23_000001_create_example\s+ran\s+1\s+2026-01-01 00:00:00/'); + + $this->assertSame(0, $command->runCommand()); + } + + public function test_it_shows_unavailable_recorded_migrations(): void { + [$command, $repository] = $this->newCommand(); + + $command->runCommand([], ['initialize' => true]); + $repository->recordRun('2026_06_23_000002_missing_migration', 1); + + $this->expectOutputRegex('/2026_06_23_000002_missing_migration\s+unavailable/'); + + $this->assertSame(0, $command->runCommand()); + } + + /** + * @return array{Migrate, InMemoryRepository, RecordingSchema} + */ + private function newCommand(): array { + $this->loadWpCliUtilities(); + + $wpSchema = new RecordingSchema(); + $repository = new InMemoryRepository(); + $migrationTable = new MigrationTable('wp_nx_foundation_migrations'); + $lock = new InMemoryLock(); + $store = new Store($wpSchema, $lock, $migrationTable, new LockTable('wp_nx_foundation_locks')); + $command = new Migrate( + $this->container, + new CommandPrefix('foundation'), + new Migrator( + new MigrationCollection([ + new TestMigration('2026_06_23_000001_create_example'), + ]), + $repository, + $store + ) + ); + + return [$command, $repository, $wpSchema]; + } + + private function loadWpCliUtilities(): void { + $wpCliRoot = dirname(__DIR__, 4) . '/vendor/wp-cli/wp-cli'; + + if (! defined('WP_CLI_ROOT')) { + define('WP_CLI_ROOT', $wpCliRoot); + } + + require_once $wpCliRoot . '/php/utils.php'; + } +} diff --git a/tests/Unit/Database/Exceptions/QueryExceptionTest.php b/tests/Unit/Database/Exceptions/QueryExceptionTest.php new file mode 100644 index 0000000..cf53e54 --- /dev/null +++ b/tests/Unit/Database/Exceptions/QueryExceptionTest.php @@ -0,0 +1,34 @@ +assertSame('Query failed.', $exception->getMessage()); + $this->assertSame('SELECT * FROM %i WHERE id = %d', $exception->sql()); + $this->assertSame(['foundation_table', 23], $exception->bindings()); + $this->assertSame('Table does not exist.', $exception->databaseError()); + $this->assertSame($previous, $exception->getPrevious()); + } + + public function test_it_allows_missing_bindings_and_database_error(): void { + $exception = new QueryException('Query failed.', 'SELECT 1'); + + $this->assertSame([], $exception->bindings()); + $this->assertNull($exception->databaseError()); + } +} diff --git a/tests/Unit/Database/Lock/DatabaseLockTest.php b/tests/Unit/Database/Lock/DatabaseLockTest.php new file mode 100644 index 0000000..c7435e9 --- /dev/null +++ b/tests/Unit/Database/Lock/DatabaseLockTest.php @@ -0,0 +1,206 @@ +database = new FakeDatabase(); + $this->lock = new DatabaseLock($this->database, 'network_foundation_locks'); + } + + public function test_it_acquires_a_database_lock_when_the_written_owner_matches(): void { + $this->database->rowResults[] = [ + 'expires_at' => '2026-01-01 00:01:00.123456', + ]; + + $token = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $token); + $this->assertSame('queue:sync', $token->name); + $this->assertSame('2026-01-01 00:01:00.123456', $token->expiresAt->format('Y-m-d H:i:s.u')); + $this->assertSame('UTC', $token->expiresAt->getTimezone()->getName()); + $this->assertStringContainsString('ON DUPLICATE KEY UPDATE', $this->database->executed[0]); + $this->assertStringContainsString('TIMESTAMPADD(SECOND, 60, UTC_TIMESTAMP(6))', $this->database->executed[0]); + $this->assertStringNotContainsString('VALUES(owner)', $this->database->executed[0]); + $this->assertStringContainsString('owner =', $this->database->rowQueries[0]); + $this->assertStringContainsString('expires_at > UTC_TIMESTAMP(6)', $this->database->rowQueries[0]); + } + + public function test_it_returns_null_when_the_acquired_lease_is_not_active_during_readback(): void { + $this->database->rowResults[] = null; + + $this->assertNull($this->lock->acquire('queue:sync', 60)); + $this->assertStringContainsString('owner =', $this->database->rowQueries[0]); + $this->assertStringContainsString('expires_at > UTC_TIMESTAMP(6)', $this->database->rowQueries[0]); + } + + public function test_it_releases_a_lock_for_the_matching_owner(): void { + $token = new LockToken( + name: 'queue:sync', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + + $this->database->executeResults[] = 1; + + $this->assertTrue($this->lock->release($token)); + $this->assertStringContainsString('DELETE FROM `network_foundation_locks`', $this->database->executed[0]); + $this->assertStringContainsString('owner', $this->database->executed[0]); + $this->assertStringContainsString('expires_at > UTC_TIMESTAMP(6)', $this->database->executed[0]); + } + + public function test_it_refreshes_a_lock_for_the_matching_owner(): void { + $token = new LockToken( + name: 'queue:sync', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + + $this->database->executeResults[] = 1; + $this->database->rowResults[] = ['expires_at' => '2026-01-01 00:02:00.654321']; + + $refreshed = $this->lock->refresh($token, 120); + + $this->assertInstanceOf(LockToken::class, $refreshed); + $this->assertSame('2026-01-01 00:02:00.654321', $refreshed->expiresAt->format('Y-m-d H:i:s.u')); + $this->assertStringContainsString('UPDATE `network_foundation_locks`', $this->database->executed[0]); + $this->assertStringContainsString('TIMESTAMPADD(SECOND, 120, UTC_TIMESTAMP(6))', $this->database->executed[0]); + } + + public function test_it_returns_null_when_a_refreshed_lease_is_not_active_during_readback(): void { + $token = new LockToken( + name: 'queue:sync', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + + $this->database->executeResults[] = 0; + + $this->assertNull($this->lock->refresh($token, 120)); + } + + public function test_it_returns_a_token_when_refresh_reports_no_change_but_the_lease_is_active(): void { + $token = new LockToken( + name: 'queue:sync', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + + $this->database->executeResults[] = 0; + $this->database->rowResults[] = ['expires_at' => '2026-01-01 00:02:00.000000']; + + $this->assertInstanceOf(LockToken::class, $this->lock->refresh($token, 120)); + } + + public function test_it_returns_null_when_a_refreshed_lock_can_no_longer_be_read(): void { + $token = new LockToken( + name: 'queue:sync', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + + $this->database->executeResults[] = 1; + $this->database->rowResults[] = null; + + $this->assertNull($this->lock->refresh($token, 120)); + } + + public function test_it_fails_closed_when_the_database_omits_the_lock_expiration(): void { + $this->database->rowResults[] = []; + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('invalid lock expiration'); + + $this->lock->acquire('queue:sync', 60); + } + + public function test_it_fails_closed_when_the_database_returns_an_invalid_lock_expiration(): void { + $this->database->rowResults[] = [ + 'expires_at' => 'invalid', + ]; + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('invalid lock expiration'); + + $this->lock->acquire('queue:sync', 60); + } + + public function test_it_checks_whether_a_lock_is_acquired(): void { + $this->database->rowResults[] = ['name' => 'queue:sync']; + + $this->assertTrue($this->lock->isAcquired('queue:sync')); + $this->assertStringContainsString('expires_at > UTC_TIMESTAMP(6)', $this->database->rowQueries[0]); + } + + public function test_it_rejects_an_invalid_ttl(): void { + $this->expectException(InvalidArgumentException::class); + + $this->lock->acquire('queue:sync', 0); + } + + public function test_it_rejects_an_empty_name(): void { + $this->expectException(InvalidArgumentException::class); + + $this->lock->isAcquired(''); + } + + public function test_it_rejects_names_longer_than_the_database_column(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('cannot exceed 191 bytes'); + + $this->lock->acquire(str_repeat('a', 192), 60); + } + + /** + * @dataProvider unavailableOperationProvider + */ + #[DataProvider('unavailableOperationProvider')] + public function test_it_normalizes_database_failures(callable $operation, string $databaseMethod): void { + $database = $this->mock(Database::class); + + $database->shouldReceive($databaseMethod)->andThrow(new QueryException('Query failed.', 'SELECT 1')); + + try { + $operation(new DatabaseLock($database, 'wp_nx_foundation_locks')); + $this->fail('Expected the database failure to be normalized.'); + } catch (LockUnavailableException $exception) { + $this->assertInstanceOf(QueryException::class, $exception->getPrevious()); + } + } + + /** + * @return array + */ + public static function unavailableOperationProvider(): array { + $token = new LockToken( + name: 'queue:sync', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + + return [ + 'acquire' => [static fn (DatabaseLock $lock): ?LockToken => $lock->acquire('queue:sync', 60), 'execute'], + 'release' => [static fn (DatabaseLock $lock): bool => $lock->release($token), 'execute'], + 'refresh' => [static fn (DatabaseLock $lock): ?LockToken => $lock->refresh($token, 60), 'execute'], + 'is acquired' => [static fn (DatabaseLock $lock): bool => $lock->isAcquired('queue:sync'), 'row'], + ]; + } +} diff --git a/tests/Unit/Database/Migration/CollectionTest.php b/tests/Unit/Database/Migration/CollectionTest.php new file mode 100644 index 0000000..8ffce85 --- /dev/null +++ b/tests/Unit/Database/Migration/CollectionTest.php @@ -0,0 +1,76 @@ +add($second); + + $indexed = [ + $first->id() => $first, + $second->id() => $second, + ]; + + $this->assertSame($indexed, $collection->all()); + $this->assertSame([$first, $second], $collection->values()); + $this->assertSame($indexed, iterator_to_array($collection)); + } + + public function test_it_rejects_duplicate_migration_ids(): void { + $this->expectException(DuplicateMigration::class); + + new Collection([ + new TestMigration('2026_01_01_000001_create_users'), + new TestMigration('2026_01_01_000001_create_users'), + ]); + } + + public function test_it_rejects_blank_migration_ids(): void { + $this->expectException(InvalidMigrationId::class); + + new Collection([new TestMigration(' ')]); + } + + public function test_it_rejects_padded_migration_ids(): void { + $this->expectException(InvalidMigrationId::class); + + new Collection([new TestMigration(' migration')]); + } + + public function test_it_rejects_migration_ids_larger_than_the_ledger_column(): void { + $this->expectException(InvalidMigrationId::class); + $this->expectExceptionMessage('cannot exceed 191 bytes'); + + new Collection([new TestMigration(str_repeat('a', 192))]); + } + + public function test_it_rejects_integer_like_migration_ids_that_php_would_coerce_to_array_keys(): void { + $this->expectException(InvalidMigrationId::class); + $this->expectExceptionMessage('cannot be integer-like strings'); + + new Collection([new TestMigration('123')]); + } + + public function test_it_accepts_case_distinct_ids_at_the_maximum_length(): void { + $upper = new TestMigration(str_repeat('A', 191)); + $lower = new TestMigration(str_repeat('a', 191)); + $collection = new Collection([$upper, $lower]); + + $this->assertSame([ + $upper->id() => $upper, + $lower->id() => $lower, + ], $collection->all()); + $this->assertSame([$upper, $lower], $collection->values()); + } +} diff --git a/tests/Unit/Database/Migration/MigratorExecutionTest.php b/tests/Unit/Database/Migration/MigratorExecutionTest.php new file mode 100644 index 0000000..620fff1 --- /dev/null +++ b/tests/Unit/Database/Migration/MigratorExecutionTest.php @@ -0,0 +1,583 @@ +repository = new InMemoryRepository(); + $this->schema = new RecordingSchema(); + $this->lock = new InMemoryLock(new MutableClock(new DateTimeImmutable('2026-01-01 00:00:00'))); + + (new Store( + $this->schema, + $this->lock, + new MigrationTable('wp_nx_foundation_migrations'), + new LockTable('wp_nx_foundation_locks') + ))->initialize(); + + $this->schema->statements = []; + } + + public function test_it_rejects_a_blank_migration_lock_name(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('lock name cannot be empty'); + + new Store( + $this->schema, + $this->lock, + new MigrationTable('wp_nx_foundation_migrations'), + new LockTable('wp_nx_foundation_locks'), + lockName: ' ' + ); + } + + public function test_it_rejects_an_invalid_migration_lock_ttl(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('TTL must be at least one second'); + + new Store( + $this->schema, + $this->lock, + new MigrationTable('wp_nx_foundation_migrations'), + new LockTable('wp_nx_foundation_locks'), + lockTtl: 0 + ); + } + + public function test_it_runs_pending_migrations_in_order(): void { + $result = $this->configured( + new TestMigration('2026_01_01_000001_create_users'), + new TestMigration('2026_01_01_000002_create_posts'), + )->run(); + + $this->assertSame([ + '2026_01_01_000001_create_users', + '2026_01_01_000002_create_posts', + ], $result->ran); + $this->assertSame([ + 'up:2026_01_01_000001_create_users', + 'up:2026_01_01_000002_create_posts', + ], $this->schema->statements); + $this->assertSame(1, $this->repository->all()['2026_01_01_000001_create_users']->batch); + $this->assertSame(1, $this->repository->all()['2026_01_01_000002_create_posts']->batch); + } + + public function test_it_skips_migrations_that_have_already_run(): void { + $this->configured( + new TestMigration('2026_01_01_000001_create_users'), + )->run(); + + $result = $this->configured( + new TestMigration('2026_01_01_000001_create_users'), + new TestMigration('2026_01_01_000002_create_posts'), + )->run(); + + $this->assertSame(['2026_01_01_000002_create_posts'], $result->ran); + $this->assertSame(['2026_01_01_000001_create_users'], $result->skipped); + $this->assertSame(2, $this->repository->all()['2026_01_01_000002_create_posts']->batch); + } + + public function test_it_rolls_back_the_latest_batch_in_reverse_order(): void { + $this->configured( + new TestMigration('2026_01_01_000001_create_users'), + )->run(); + $this->configured( + new TestMigration('2026_01_01_000002_create_posts'), + new TestMigration('2026_01_01_000003_create_comments'), + )->run(); + + $this->schema->statements = []; + + $result = $this->configured( + new TestMigration('2026_01_01_000001_create_users'), + new TestMigration('2026_01_01_000002_create_posts'), + new TestMigration('2026_01_01_000003_create_comments'), + )->rollback(); + + $this->assertSame([ + '2026_01_01_000003_create_comments', + '2026_01_01_000002_create_posts', + ], $result->rolledBack); + $this->assertSame([ + 'down:2026_01_01_000003_create_comments', + 'down:2026_01_01_000002_create_posts', + ], $this->schema->statements); + $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); + $this->assertFalse($this->repository->hasRun('2026_01_01_000002_create_posts')); + } + + public function test_it_rolls_back_an_explicit_batch_when_it_is_still_latest(): void { + $this->configured( + new TestMigration('2026_01_01_000001_create_users'), + )->run(); + $this->configured( + new TestMigration('2026_01_01_000002_create_posts'), + )->run(); + + $result = $this->configured( + new TestMigration('2026_01_01_000001_create_users'), + new TestMigration('2026_01_01_000002_create_posts'), + )->rollback(2); + + $this->assertSame(['2026_01_01_000002_create_posts'], $result->rolledBack); + $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); + $this->assertFalse($this->repository->hasRun('2026_01_01_000002_create_posts')); + } + + public function test_it_rejects_rolling_back_an_older_batch(): void { + $this->configured( + new TestMigration('2026_01_01_000001_create_users'), + )->run(); + $this->configured( + new TestMigration('2026_01_01_000002_create_posts'), + )->run(); + + $this->schema->statements = []; + + $this->expectException(InvalidRollbackBatch::class); + $this->expectExceptionMessage('batch 1 cannot be rolled back because the latest recorded batch is 2'); + + try { + $this->configured( + new TestMigration('2026_01_01_000001_create_users'), + new TestMigration('2026_01_01_000002_create_posts'), + )->rollback(1); + } finally { + $this->assertSame([], $this->schema->statements); + $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); + $this->assertTrue($this->repository->hasRun('2026_01_01_000002_create_posts')); + } + } + + public function test_it_returns_an_empty_result_when_there_is_no_batch_to_roll_back(): void { + $result = $this->configured( + new TestMigration('2026_01_01_000001_create_users'), + )->rollback(); + + $this->assertSame([], $result->rolledBack); + $this->assertSame(0, $result->count()); + } + + public function test_it_rejects_an_explicit_batch_when_no_batch_remains(): void { + $this->expectException(InvalidRollbackBatch::class); + $this->expectExceptionMessage('batch 1 cannot be rolled back because the latest recorded batch is none'); + + $this->configured( + new TestMigration('2026_01_01_000001_create_users'), + )->rollback(1); + } + + public function test_it_rejects_unavailable_rollback_records_before_changing_schema(): void { + $this->repository->recordRun('2026_01_01_000001_create_users', 1); + $this->repository->recordRun('2026_01_01_000001_missing_migration', 1); + + $this->expectException(UnavailableMigration::class); + $this->expectExceptionMessage('2026_01_01_000001_missing_migration'); + + try { + $this->configured( + new TestMigration('2026_01_01_000001_create_users'), + )->rollback(); + } finally { + $this->assertSame([], $this->schema->statements); + $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); + } + } + + public function test_it_rejects_unavailable_refresh_records_before_changing_schema(): void { + $this->repository->recordRun('2026_01_01_000001_create_users', 1); + $this->repository->recordRun('2026_01_01_000002_missing_migration', 1); + + $this->expectException(UnavailableMigration::class); + + try { + $this->configured( + new TestMigration('2026_01_01_000001_create_users'), + )->refresh(); + } finally { + $this->assertSame([], $this->schema->statements); + $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); + } + } + + public function test_it_refreshes_all_ran_migrations_then_runs_them_again(): void { + $migrations = $this->collection( + new TestMigration('2026_01_01_000001_create_users'), + new TestMigration('2026_01_01_000002_create_posts'), + ); + + $migrator = $this->migrator($migrations); + $migrator->run(); + $this->schema->statements = []; + + $result = $migrator->refresh(); + + $this->assertSame([ + '2026_01_01_000002_create_posts', + '2026_01_01_000001_create_users', + ], $result->rolledBack); + $this->assertSame([ + '2026_01_01_000001_create_users', + '2026_01_01_000002_create_posts', + ], $result->ran); + $this->assertSame([ + 'down:2026_01_01_000002_create_posts', + 'down:2026_01_01_000001_create_users', + 'up:2026_01_01_000001_create_users', + 'up:2026_01_01_000002_create_posts', + ], $this->schema->statements); + } + + public function test_refresh_uses_one_migration_snapshot_for_rollback_and_run(): void { + $collection = new Collection(); + $late = new TestMigration('2026_01_01_000002_create_posts'); + $migration = $this->createMock(Migration::class); + + $migration->method('id')->willReturn('2026_01_01_000001_create_users'); + $migration->method('up') + ->willReturnCallback(static function (Schema $schema): void { + $schema->execute('up:2026_01_01_000001_create_users'); + }); + $migration->method('down') + ->willReturnCallback(static function (Schema $schema) use ($collection, $late): void { + $schema->execute('down:2026_01_01_000001_create_users'); + $collection->add($late); + }); + + $collection->add($migration); + $migrator = $this->migrator($collection); + $migrator->run(); + $result = $migrator->refresh(); + + $this->assertSame(['2026_01_01_000001_create_users'], $result->rolledBack); + $this->assertSame(['2026_01_01_000001_create_users'], $result->ran); + $this->assertSame([$migration, $late], $collection->values()); + } + + public function test_it_returns_status_for_configured_migrations(): void { + $this->configured( + new TestMigration('2026_01_01_000001_create_users'), + )->run(); + + $statuses = $this->configured( + new TestMigration('2026_01_01_000001_create_users'), + new TestMigration('2026_01_01_000002_create_posts'), + )->status(); + + $this->assertTrue($statuses[0]->ran); + $this->assertSame(1, $statuses[0]->batch); + $this->assertFalse($statuses[1]->ran); + $this->assertNull($statuses[1]->batch); + } + + public function test_it_returns_status_for_unavailable_recorded_migrations(): void { + $migrator = $this->configured( + new TestMigration('2026_01_01_000002_create_posts'), + ); + $this->repository->recordRun('2026_01_01_000001_missing_migration', 1); + + $statuses = $migrator->status(); + + $this->assertTrue($statuses[0]->available); + $this->assertFalse($statuses[0]->ran); + $this->assertFalse($statuses[1]->available); + $this->assertTrue($statuses[1]->ran); + $this->assertSame('2026_01_01_000001_missing_migration', $statuses[1]->migration); + } + + public function test_it_treats_migration_ids_as_case_sensitive(): void { + $result = $this->configured( + new TestMigration('CreateReports'), + new TestMigration('createreports'), + )->run(); + + $this->assertSame(['CreateReports', 'createreports'], $result->ran); + $this->assertCount(2, $this->repository->all()); + } + + public function test_it_fails_when_the_migration_lock_is_already_owned(): void { + $this->lock->acquire('nx-foundation-database-migrations', 300); + + $this->expectException(MigrationLockFailed::class); + $this->expectExceptionMessage('Could not acquire migration lock'); + + $this->configured( + new TestMigration('2026_01_01_000001_create_users'), + )->run(); + } + + public function test_it_propagates_lock_acquisition_failures_with_the_configured_policy(): void { + $lock = $this->createMock(Lock::class); + $lock->expects($this->once()) + ->method('acquire') + ->with('custom-migrations', 120) + ->willThrowException(new LockUnavailableException('Lock backend unavailable.')); + $lock->expects($this->never()) + ->method('release'); + + $migrator = $this->migrator( + $this->collection(new TestMigration('2026_01_01_000001_create_users')), + lock: $lock, + lockName: 'custom-migrations', + lockTtl: 120 + ); + + $this->expectException(LockUnavailableException::class); + + $migrator->run(); + } + + public function test_it_releases_the_lock_when_initialization_fails(): void { + $storeSchema = $this->createMock(Schema::class); + $storeSchema->method('createOrUpdate') + ->willReturnCallback(static function (Table $table): void { + if ($table instanceof MigrationTable) { + throw new DatabaseException('Could not prepare the migration ledger.'); + } + }); + + $store = new Store( + $storeSchema, + $this->lock, + new MigrationTable('wp_nx_foundation_migrations'), + new LockTable('wp_nx_foundation_locks') + ); + + $this->expectException(DatabaseException::class); + + try { + $store->initialize(); + } finally { + $this->assertNotNull($this->lock->acquire('nx-foundation-database-migrations', 300)); + } + } + + public function test_it_fails_when_migration_lock_ownership_cannot_be_confirmed_during_release(): void { + $token = $this->lockToken(); + $lock = $this->createMock(Lock::class); + $lock->expects($this->once()) + ->method('acquire') + ->with('nx-foundation-database-migrations', 300) + ->willReturn($token); + $lock->expects($this->once()) + ->method('release') + ->with($token) + ->willReturn(false); + + $migrator = $this->migrator( + $this->collection(new TestMigration('2026_01_01_000001_create_users')), + lock: $lock, + ); + + $this->expectException(MigrationLockFailed::class); + $this->expectExceptionMessage('Could not confirm ownership'); + + try { + $migrator->run(); + } finally { + $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); + } + } + + public function test_it_preserves_the_migration_failure_when_lock_release_is_unavailable(): void { + $token = $this->lockToken(); + $lock = $this->createMock(Lock::class); + $lock->expects($this->once()) + ->method('acquire') + ->willReturn($token); + $lock->expects($this->once()) + ->method('release') + ->with($token) + ->willThrowException(new LockUnavailableException('Lock backend unavailable.')); + + $migrator = $this->migrator( + $this->collection(new FailingMigration('2026_01_01_000001_create_users', failUp: true)), + lock: $lock, + ); + + $this->expectException(MigrationFailed::class); + $this->expectExceptionMessage('failed while running'); + + $migrator->run(); + } + + public function test_it_propagates_lock_release_failures_after_a_successful_migration(): void { + $token = $this->lockToken(); + $lock = $this->createMock(Lock::class); + $lock->expects($this->once()) + ->method('acquire') + ->willReturn($token); + $lock->expects($this->once()) + ->method('release') + ->with($token) + ->willThrowException(new LockUnavailableException('Lock backend unavailable.')); + + $migrator = $this->migrator( + $this->collection(new TestMigration('2026_01_01_000001_create_users')), + lock: $lock, + ); + + $this->expectException(LockUnavailableException::class); + + try { + $migrator->run(); + } finally { + $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); + } + } + + public function test_it_preserves_the_migration_failure_when_release_cannot_confirm_ownership(): void { + $token = $this->lockToken(); + $lock = $this->createMock(Lock::class); + $lock->expects($this->once()) + ->method('acquire') + ->willReturn($token); + $lock->expects($this->once()) + ->method('release') + ->with($token) + ->willReturn(false); + + $migrator = $this->migrator( + $this->collection(new FailingMigration('2026_01_01_000001_create_users', failUp: true)), + lock: $lock, + ); + + $this->expectException(MigrationFailed::class); + $this->expectExceptionMessage('failed while running'); + + $migrator->run(); + } + + public function test_it_does_not_record_a_failed_migration(): void { + $this->expectException(MigrationFailed::class); + $this->expectExceptionMessage('failed while running'); + + try { + $this->configured( + new FailingMigration('2026_01_01_000001_create_users', failUp: true), + )->run(); + } finally { + $this->assertFalse($this->repository->hasRun('2026_01_01_000001_create_users')); + } + } + + public function test_it_does_not_delete_a_record_when_rollback_fails(): void { + $this->configured( + new TestMigration('2026_01_01_000001_create_users'), + )->run(); + + $this->expectException(MigrationFailed::class); + $this->expectExceptionMessage('failed while rolling back'); + + try { + $this->configured( + new FailingMigration('2026_01_01_000001_create_users', failDown: true), + )->rollback(); + } finally { + $this->assertTrue($this->repository->hasRun('2026_01_01_000001_create_users')); + } + } + + public function test_it_fails_when_a_rolled_back_ledger_record_is_not_deleted(): void { + $migration = new TestMigration('2026_01_01_000001_create_users'); + $repository = $this->createMock(Repository::class); + $repository->method('latestBatch')->willReturn(1); + $repository->method('recordsForBatch')->willReturn([ + new Record(1, $migration->id(), 1, new DateTimeImmutable('2026-01-01 00:00:00')), + ]); + $repository->expects($this->once()) + ->method('deleteRun') + ->with($migration->id()) + ->willReturn(false); + + $this->expectException(LedgerFailure::class); + $this->expectExceptionMessage('was rolled back but its ledger record was not deleted'); + + try { + $this->migrator($this->collection($migration), repository: $repository)->rollback(); + } finally { + $this->assertSame(['down:' . $migration->id()], $this->schema->statements); + } + } + + private function lockToken(): LockToken { + $token = $this->lock->acquire('nx-foundation-database-migrations', 300); + + $this->assertNotNull($token); + + return $token; + } + + private function configured(Migration ...$migrations): Migrator { + return $this->migrator($this->collection(...$migrations)); + } + + private function migrator( + Collection $migrations, + ?Lock $lock = null, + ?Schema $schema = null, + ?Repository $repository = null, + string $lockName = 'nx-foundation-database-migrations', + int $lockTtl = 300 + ): Migrator { + $schema ??= $this->schema; + $lock ??= $this->lock; + $repository ??= $this->repository; + $store = new Store( + $schema, + $lock, + new MigrationTable('wp_nx_foundation_migrations'), + new LockTable('wp_nx_foundation_locks'), + $lockName, + $lockTtl + ); + + return new Migrator( + $migrations, + $repository, + $store + ); + } + + private function collection(Migration ...$migrations): Collection { + return new Collection($migrations); + } +} diff --git a/tests/Unit/Database/Migration/MigratorTest.php b/tests/Unit/Database/Migration/MigratorTest.php new file mode 100644 index 0000000..09b5c54 --- /dev/null +++ b/tests/Unit/Database/Migration/MigratorTest.php @@ -0,0 +1,228 @@ +newMigrator(); + + $result = $migrator->run(); + + $this->assertSame(['2026_06_23_000001_create_example'], $result->ran); + $this->assertTrue($repository->hasRun('2026_06_23_000001_create_example')); + $this->assertSame([ + 'up:2026_06_23_000001_create_example', + ], $schema->statements); + } + + public function test_it_rolls_back_configured_migrations_against_initialized_storage(): void { + [$migrator, $repository, $schema] = $this->newMigrator(); + + $migrator->run(); + $schema->statements = []; + + $result = $migrator->rollback(); + + $this->assertSame(['2026_06_23_000001_create_example'], $result->rolledBack); + $this->assertFalse($repository->hasRun('2026_06_23_000001_create_example')); + $this->assertSame([ + 'down:2026_06_23_000001_create_example', + ], $schema->statements); + } + + public function test_it_refreshes_configured_migrations_against_initialized_storage(): void { + [$migrator, $repository, $schema] = $this->newMigrator(); + + $migrator->run(); + $schema->statements = []; + + $result = $migrator->refresh(); + + $this->assertSame(['2026_06_23_000001_create_example'], $result->rolledBack); + $this->assertSame(['2026_06_23_000001_create_example'], $result->ran); + $this->assertTrue($repository->hasRun('2026_06_23_000001_create_example')); + $this->assertSame([ + 'down:2026_06_23_000001_create_example', + 'up:2026_06_23_000001_create_example', + ], $schema->statements); + } + + public function test_it_exposes_migration_status_for_configured_migrations(): void { + [$migrator, , $schema] = $this->newMigrator(); + + $this->assertFalse($migrator->status()[0]->ran); + $this->assertSame([], $schema->statements); + + $migrator->run(); + + $this->assertTrue($migrator->status()[0]->ran); + } + + public function test_it_initializes_and_drops_the_migration_store(): void { + [$migrator, , $schema] = $this->newMigrator(initialize: false); + + $this->assertFalse($migrator->isInitialized()); + + $migrator->initialize(); + + $this->assertTrue($migrator->isInitialized()); + + $migrator->dropStore(); + + $this->assertFalse($migrator->isInitialized()); + $this->assertContains('drop:wp_nx_foundation_migrations', $schema->statements); + $this->assertNotContains('drop:wp_nx_foundation_locks', $schema->statements); + $this->assertTrue($schema->tables['wp_nx_foundation_locks']); + } + + public function test_it_does_not_drop_the_store_while_another_migration_owns_the_lock(): void { + $lock = new InMemoryLock(); + [$migrator, , $schema] = $this->newMigrator($lock); + + $token = $lock->acquire('nx-foundation-database-migrations', 300); + + $this->assertNotNull($token); + $this->expectException(MigrationLockFailed::class); + + try { + $migrator->dropStore(); + } finally { + $this->assertTrue($schema->tables['wp_nx_foundation_migrations']); + } + } + + public function test_it_does_not_initialize_the_ledger_while_another_migration_owns_the_lock(): void { + $lock = new InMemoryLock(); + [$migrator, , $schema] = $this->newMigrator($lock, false); + $token = $lock->acquire('nx-foundation-database-migrations', 300); + + $this->assertNotNull($token); + $this->expectException(MigrationLockFailed::class); + + try { + $migrator->initialize(); + } finally { + $this->assertTrue($schema->tables['wp_nx_foundation_locks']); + $this->assertArrayNotHasKey('wp_nx_foundation_migrations', $schema->tables); + } + } + + public function test_status_uses_the_existing_ledger_when_shared_lock_storage_is_missing(): void { + [$migrator, , $schema] = $this->newMigrator(); + + $migrator->run(); + unset($schema->tables['wp_nx_foundation_locks']); + + $this->assertFalse($migrator->isInitialized()); + $this->assertTrue($migrator->status()[0]->ran); + } + + public function test_it_rejects_migration_operations_before_storage_is_initialized(): void { + [$migrator, , $schema] = $this->newMigrator(initialize: false); + + $this->expectException(UninitializedStore::class); + + try { + $migrator->run(); + } finally { + $this->assertSame([], $schema->statements); + } + } + + public function test_it_does_not_acquire_the_migration_lock_without_lock_storage(): void { + $lock = $this->createMock(Lock::class); + $lock->expects($this->never())->method('acquire'); + + [$migrator, , $schema] = $this->newMigrator($lock, initialize: false); + $schema->tables['wp_nx_foundation_migrations'] = true; + + $this->expectException(UninitializedStore::class); + + $migrator->run(); + } + + public function test_it_rechecks_storage_after_acquiring_the_migration_lock(): void { + $schema = new RecordingSchema(); + $migrationTable = new MigrationTable('wp_nx_foundation_migrations'); + $lockTable = new LockTable('wp_nx_foundation_locks'); + $token = new LockToken( + 'nx-foundation-database-migrations', + 'owner', + new DateTimeImmutable('+5 minutes') + ); + $lock = $this->createMock(Lock::class); + + $schema->tables[$migrationTable->name()] = true; + $schema->tables[$lockTable->name()] = true; + + $lock->expects($this->once()) + ->method('acquire') + ->willReturnCallback(static function () use ($schema, $migrationTable, $token): LockToken { + unset($schema->tables[$migrationTable->name()]); + + return $token; + }); + $lock->expects($this->once()) + ->method('release') + ->with($token) + ->willReturn(true); + + $migrator = new Migrator( + new Collection([new TestMigration('2026_06_23_000001_create_example')]), + new InMemoryRepository(), + new Store($schema, $lock, $migrationTable, $lockTable) + ); + + $this->expectException(UninitializedStore::class); + + $migrator->run(); + } + + /** + * @return array{Migrator, InMemoryRepository, RecordingSchema} + */ + private function newMigrator(?Lock $lock = null, bool $initialize = true): array { + $schema = new RecordingSchema(); + $repository = new InMemoryRepository(); + $lock ??= new InMemoryLock(); + $migrationTable = new MigrationTable('wp_nx_foundation_migrations'); + $lockTable = new LockTable('wp_nx_foundation_locks'); + $store = new Store($schema, $lock, $migrationTable, $lockTable); + + $migrator = new Migrator( + new Collection([ + new TestMigration('2026_06_23_000001_create_example'), + ]), + $repository, + $store + ); + + if ($initialize) { + $migrator->initialize(); + $schema->statements = []; + } + + return [ + $migrator, + $repository, + $schema, + ]; + } +} diff --git a/tests/Unit/Database/Migration/RepositoryTest.php b/tests/Unit/Database/Migration/RepositoryTest.php new file mode 100644 index 0000000..6e0dd83 --- /dev/null +++ b/tests/Unit/Database/Migration/RepositoryTest.php @@ -0,0 +1,118 @@ +database = new FakeDatabase(); + $this->repository = new Repository($this->database, 'network_foundation_migrations'); + } + + public function test_it_returns_all_migration_records_indexed_by_migration_id(): void { + $this->database->rowsResults[] = [ + [ + 'id' => 1, + 'migration' => '2026_01_01_000001_create_users', + 'batch' => 1, + 'ran_at' => '2026-01-01 00:00:00', + ], + ]; + + $records = $this->repository->all(); + + $this->assertArrayHasKey('2026_01_01_000001_create_users', $records); + $this->assertSame(1, $records['2026_01_01_000001_create_users']->id); + } + + public function test_it_records_a_migration_run(): void { + $this->database->rowResults[] = [ + 'id' => 1, + 'migration' => '2026_01_01_000001_create_users', + 'batch' => 2, + 'ran_at' => '2026-01-01 00:00:00', + ]; + + $record = $this->repository->recordRun('2026_01_01_000001_create_users', 2); + + $this->assertSame(2, $record->batch); + $this->assertStringContainsString('INSERT INTO `network_foundation_migrations`', $this->database->executed[0]); + } + + public function test_it_fails_when_an_inserted_migration_cannot_be_read_back(): void { + $this->expectException(LedgerFailure::class); + $this->expectExceptionMessage('was inserted but could not be read from the ledger'); + + try { + $this->repository->recordRun('2026_01_01_000001_create_users', 2); + } finally { + $this->assertStringContainsString('INSERT INTO `network_foundation_migrations`', $this->database->executed[0]); + } + } + + public function test_it_rejects_invalid_migration_ids_before_writing_to_the_ledger(): void { + $this->expectException(InvalidMigrationId::class); + + $this->repository->recordRun(' invalid', 2); + } + + public function test_it_rejects_invalid_migration_ids_read_from_the_ledger(): void { + $this->database->rowsResults[] = [[ + 'id' => 1, + 'migration' => '123', + 'batch' => 1, + 'ran_at' => '2026-01-01 00:00:00', + ]]; + + $this->expectException(InvalidMigrationId::class); + + $this->repository->all(); + } + + public function test_it_deletes_a_migration_run(): void { + $this->database->executeResults[] = 1; + + $this->assertTrue($this->repository->deleteRun('2026_01_01_000001_create_users')); + $this->assertStringContainsString('DELETE FROM `network_foundation_migrations`', $this->database->executed[0]); + } + + public function test_it_reports_when_no_migration_run_was_deleted(): void { + $this->database->executeResults[] = 0; + + $this->assertFalse($this->repository->deleteRun('2026_01_01_000001_create_users')); + } + + public function test_it_calculates_the_next_batch(): void { + $this->database->rowResults[] = ['batch' => 4]; + + $this->assertSame(5, $this->repository->nextBatch()); + } + + public function test_it_returns_records_for_a_batch(): void { + $this->database->rowsResults[] = [ + [ + 'id' => 2, + 'migration' => '2026_01_01_000002_create_posts', + 'batch' => 3, + 'ran_at' => '2026-01-01 00:00:00', + ], + ]; + + $records = $this->repository->recordsForBatch(3); + + $this->assertCount(1, $records); + $this->assertSame('2026_01_01_000002_create_posts', $records[0]->migration); + } +} diff --git a/tests/Unit/Database/Migration/ValueObjects/ResultTest.php b/tests/Unit/Database/Migration/ValueObjects/ResultTest.php new file mode 100644 index 0000000..70dce8a --- /dev/null +++ b/tests/Unit/Database/Migration/ValueObjects/ResultTest.php @@ -0,0 +1,19 @@ +assertSame(2, $result->count()); + } +} diff --git a/tests/Unit/Database/Query/QueryBuilderTest.php b/tests/Unit/Database/Query/QueryBuilderTest.php new file mode 100644 index 0000000..8e3c5bd --- /dev/null +++ b/tests/Unit/Database/Query/QueryBuilderTest.php @@ -0,0 +1,166 @@ +table($table, 'r') + ->select('id', 'title') + ->where('status', '=', 'published') + ->orderBy('id', 'DESC') + ->limit(10, 5); + + $this->assertSame( + 'SELECT `id`, `title` FROM `wp_reports` AS `r` WHERE `status` = %s ORDER BY `id` DESC LIMIT %d OFFSET %d', + $query->toSql() + ); + $this->assertSame(['published', 10, 5], $query->bindings()); + $this->assertSame( + "SELECT `id`, `title` FROM `wp_reports` AS `r` WHERE `status` = 'published' ORDER BY `id` DESC LIMIT 10 OFFSET 5", + $query->toPreparedSql() + ); + } + + public function test_it_quotes_qualified_columns_and_select_wildcards_by_segment(): void { + $query = (new FakeDatabase()) + ->table('posts', 'p') + ->select('*', 'p.ID', 'p.*') + ->where('p.status', '=', 'publish') + ->where('p.deleted_at', '=', null) + ->orderBy('p.ID'); + + $this->assertSame( + 'SELECT *, `p`.`ID`, `p`.* FROM `wp_posts` AS `p` WHERE `p`.`status` = %s AND `p`.`deleted_at` IS NULL ORDER BY `p`.`ID` ASC', + $query->toSql() + ); + $this->assertSame(['publish'], $query->bindings()); + } + + public function test_it_escapes_each_qualified_column_segment(): void { + $query = (new FakeDatabase())->table('posts')->where('p`ost.I`D', '=', 1); + + $this->assertSame('SELECT * FROM `wp_posts` WHERE `p``ost`.`I``D` = %s', $query->toSql()); + } + + /** + * @dataProvider invalidColumnProvider + */ + #[DataProvider('invalidColumnProvider')] + public function test_it_rejects_invalid_qualified_columns(string $column): void { + $this->expectException(InvalidArgumentException::class); + + (new FakeDatabase())->table('posts')->select($column)->toSql(); + } + + /** + * @return iterable + */ + public static function invalidColumnProvider(): iterable { + yield 'empty segment' => ['p..ID']; + + yield 'non-terminal wildcard' => ['p.*.ID']; + } + + public function test_it_rejects_wildcards_outside_selects(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid query column wildcard'); + + (new FakeDatabase())->table('posts')->where('p.*', '=', 1); + } + + public function test_it_rejects_unsupported_operators(): void { + $this->expectException(InvalidArgumentException::class); + + (new FakeDatabase())->table('reports')->where('status', 'BETWEEN', ['a', 'z']); + } + + public function test_it_builds_null_comparisons_without_bindings(): void { + $query = (new FakeDatabase()) + ->table('reports') + ->where('deleted_at', '=', null) + ->where('archived_at', '!=', null) + ->where('expired_at', '<>', null) + ->where('status', '=', 'published'); + + $this->assertSame( + 'SELECT * FROM `wp_reports` WHERE `deleted_at` IS NULL AND `archived_at` IS NOT NULL AND `expired_at` IS NOT NULL AND `status` = %s', + $query->toSql() + ); + $this->assertSame(['published'], $query->bindings()); + } + + public function test_it_rejects_invalid_null_comparisons(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('NULL comparisons only support =, !=, and <> operators.'); + + (new FakeDatabase())->table('reports')->where('updated_at', '>', null); + } + + public function test_it_rejects_invalid_order_directions(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Order direction must be ASC or DESC.'); + + (new FakeDatabase())->table('reports')->orderBy('id', 'SIDEWAYS'); + } + + public function test_it_rejects_invalid_limits(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Query limit must be greater than zero.'); + + (new FakeDatabase())->table('reports')->limit(0); + } + + public function test_it_rejects_negative_offsets(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Query offset cannot be negative.'); + + (new FakeDatabase())->table('reports')->limit(10, -1); + } + + public function test_it_reads_the_first_row(): void { + $database = new FakeDatabase(); + $database->rowResults[] = ['name' => 'first']; + + $this->assertSame( + ['name' => 'first'], + $database->table('reports')->where('id', '=', 1)->first() + ); + $this->assertStringEndsWith('LIMIT 1', $database->rowQueries[0]); + } + + public function test_reading_the_first_row_does_not_mutate_the_builder_and_preserves_its_offset(): void { + $database = new FakeDatabase(); + $database->rowResults[] = ['name' => 'sixth']; + $query = $database->table('reports')->limit(25, 5); + + $this->assertSame(['name' => 'sixth'], $query->first()); + $this->assertStringEndsWith('LIMIT 1 OFFSET 5', $database->rowQueries[0]); + $this->assertSame('SELECT * FROM `wp_reports` LIMIT %d OFFSET %d', $query->toSql()); + $this->assertSame([25, 5], $query->bindings()); + } + + public function test_it_selects_all_columns_by_default(): void { + $query = (new FakeDatabase())->table('reports'); + + $this->assertSame('SELECT * FROM `wp_reports`', $query->toSql()); + } + + public function test_it_builds_query_objects(): void { + $query = (new FakeDatabase())->table('reports')->where('id', '=', 10)->query(); + + $this->assertInstanceOf(Query::class, $query); + $this->assertSame('SELECT * FROM `wp_reports` WHERE `id` = %s', $query->toSql()); + $this->assertSame([10], $query->bindings()); + } +} diff --git a/tests/Unit/Database/Query/QueryTest.php b/tests/Unit/Database/Query/QueryTest.php new file mode 100644 index 0000000..089af6c --- /dev/null +++ b/tests/Unit/Database/Query/QueryTest.php @@ -0,0 +1,32 @@ +assertSame('SELECT * FROM %i WHERE status = %s', $query->toSql()); + $this->assertSame(['wp_reports', 'published'], $query->bindings()); + $this->assertSame("SELECT * FROM `wp_reports` WHERE status = 'published'", $query->toPreparedSql()); + } + + public function test_it_executes_rows_first_and_value_queries(): void { + $database = new FakeDatabase(); + $query = new Query($database, 'SELECT name FROM %i WHERE id = %d', ['wp_reports', 1]); + + $database->rowsResults[] = [['name' => 'first']]; + $database->rowResults[] = ['name' => 'first']; + $database->rowResults[] = ['count' => 3]; + + $this->assertSame([['name' => 'first']], $query->get()); + $this->assertSame(['name' => 'first'], $query->first()); + $this->assertSame(3, $query->value()); + } +} diff --git a/tests/Unit/Database/Schema/DbDeltaTest.php b/tests/Unit/Database/Schema/DbDeltaTest.php new file mode 100644 index 0000000..6b63ff9 --- /dev/null +++ b/tests/Unit/Database/Schema/DbDeltaTest.php @@ -0,0 +1,21 @@ +expectException(DatabaseException::class); + $this->expectExceptionMessage('WordPress dbDelta() is not available.'); + + (new DbDelta())->execute('CREATE TABLE example (id bigint)'); + } +} diff --git a/tests/Unit/Database/Schema/ReconcilerTest.php b/tests/Unit/Database/Schema/ReconcilerTest.php new file mode 100644 index 0000000..dd5a7f4 --- /dev/null +++ b/tests/Unit/Database/Schema/ReconcilerTest.php @@ -0,0 +1,109 @@ +rowResults[] = ['Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment']; + $executor = new RecordingSchemaExecutor(); + $reconciler = new Reconciler($database, $executor); + + $reconciler->reconcile(new TestTable('example', 'wp_example')); + + $this->assertStringContainsString('CREATE TABLE `wp_example`', $executor->statements[0]); + $this->assertSame("SHOW FULL COLUMNS FROM `wp_example` WHERE Field = 'id'", $database->rowQueries[0]); + } + + public function test_it_accepts_matching_column_defaults_and_nullability(): void { + $database = new FakeDatabase(); + $database->rowResults = [ + ['Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment'], + ['Null' => 'NO', 'Default' => '5', 'Extra' => ''], + ['Null' => 'YES', 'Default' => null, 'Extra' => ''], + ['Null' => 'NO', 'Default' => '', 'Extra' => ''], + ['Null' => 'NO', 'Default' => '1.25', 'Extra' => ''], + ['Null' => 'NO', 'Default' => "b'1'", 'Extra' => ''], + ]; + $reconciler = new Reconciler($database, new RecordingSchemaExecutor()); + + $reconciler->reconcile(new SchemaReconciliationTable('wp_example', 5, true)); + + $this->assertSame([], $database->executed); + } + + public function test_it_fails_when_column_defaults_and_nullability_remain_unapplied(): void { + $database = new FakeDatabase(); + $database->rowResults = [ + ['Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment'], + ['Null' => 'NO', 'Default' => 'not-an-integer', 'Extra' => ''], + ['Null' => 'NO', 'Default' => null, 'Extra' => ''], + ['Null' => 'NO', 'Default' => '', 'Extra' => ''], + ['Null' => 'NO', 'Default' => '1.25', 'Extra' => ''], + ['Null' => 'NO', 'Default' => "b'1'", 'Extra' => ''], + ]; + $reconciler = new Reconciler($database, new RecordingSchemaExecutor()); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('column attempts expected DEFAULT 5, found DEFAULT not-an-integer; column completed_at expected NULL, found NOT NULL'); + + $reconciler->reconcile(new SchemaReconciliationTable('wp_example', 5, true)); + } + + public function test_it_does_not_treat_a_missing_default_as_an_empty_string(): void { + $database = new FakeDatabase(); + $database->rowResults = [ + ['Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment'], + ['Null' => 'NO', 'Default' => '5', 'Extra' => ''], + ['Null' => 'NO', 'Default' => null, 'Extra' => ''], + ['Null' => 'NO', 'Default' => null, 'Extra' => ''], + ['Null' => 'NO', 'Default' => '1.25', 'Extra' => ''], + ['Null' => 'NO', 'Default' => "b'1'", 'Extra' => ''], + ]; + $reconciler = new Reconciler($database, new RecordingSchemaExecutor()); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage("column label expected DEFAULT '', found DEFAULT NULL"); + + $reconciler->reconcile(new SchemaReconciliationTable('wp_example', 5, false)); + } + + public function test_it_rejects_unapplied_column_extra_attributes(): void { + $database = new FakeDatabase(); + $database->rowResults[] = ['Null' => 'NO', 'Default' => null, 'Extra' => '']; + $reconciler = new Reconciler($database, new RecordingSchemaExecutor()); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('column id expected extra auto_increment, found none'); + + $reconciler->reconcile(new TestTable('example', 'wp_example')); + } + + public function test_it_rejects_missing_column_metadata(): void { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('could not inspect wp_example.id'); + + (new Reconciler(new FakeDatabase(), new RecordingSchemaExecutor())) + ->reconcile(new TestTable('example', 'wp_example')); + } + + public function test_it_rejects_invalid_column_metadata(): void { + $database = new FakeDatabase(); + $database->rowResults[] = ['Null' => 'MAYBE', 'Default' => null, 'Extra' => '']; + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('returned invalid column metadata for wp_example.id'); + + (new Reconciler($database, new RecordingSchemaExecutor())) + ->reconcile(new TestTable('example', 'wp_example')); + } +} diff --git a/tests/Unit/Database/SchemaTest.php b/tests/Unit/Database/SchemaTest.php new file mode 100644 index 0000000..f27b44b --- /dev/null +++ b/tests/Unit/Database/SchemaTest.php @@ -0,0 +1,40 @@ +rowResults[] = ['table' => 'wp_example']; + $database->rowResults[] = ['Key_name' => 'example_key']; + $schema = new Schema($database, new Reconciler($database, new RecordingSchemaExecutor())); + + $this->assertTrue($schema->hasTable('wp_example%')); + $this->assertTrue($schema->hasIndex('wp_example', 'example_key')); + $this->assertStringContainsString("SHOW TABLES LIKE 'wp\\\\_example\\\\%'", $database->rowQueries[0]); + $this->assertStringContainsString('SHOW INDEX FROM `wp_example`', $database->rowQueries[1]); + } + + public function test_it_drops_indexes(): void { + $database = new FakeDatabase(); + $schema = new Schema($database, new Reconciler($database, new RecordingSchemaExecutor())); + + $schema->dropIndex('wp_example', 'example_key'); + + $this->assertSame('ALTER TABLE `wp_example` DROP INDEX `example_key`', $database->executed[0]); + } + + public function test_it_exposes_identifier_helpers(): void { + $database = new FakeDatabase(); + $schema = new Schema($database, new Reconciler($database, new RecordingSchemaExecutor())); + + $this->assertSame('`weird``table`', $schema->quoteIdentifier('weird`table')); + } +} diff --git a/tests/Unit/Database/Table/ColumnTest.php b/tests/Unit/Database/Table/ColumnTest.php new file mode 100644 index 0000000..191e3c4 --- /dev/null +++ b/tests/Unit/Database/Table/ColumnTest.php @@ -0,0 +1,70 @@ +assertSame('`queue_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT', $column->sql()); + } + + public function test_it_renders_nullable_and_default_values(): void { + $this->assertSame( + "`status` varchar(20) NULL DEFAULT 'pending'", + (new Column('status', 'varchar', 20, nullable: true, default: 'pending'))->sql() + ); + + $this->assertSame( + '`attempts` int(10) unsigned NOT NULL DEFAULT 0', + (new Column('attempts', 'int', 10, unsigned: true, default: 0))->sql() + ); + } + + public function test_it_renders_explicit_null_and_boolean_defaults(): void { + $this->assertSame( + '`completed_at` datetime NULL DEFAULT NULL', + (new Column('completed_at', 'datetime'))->nullable()->default(null)->sql() + ); + + $this->assertSame( + '`enabled` tinyint(1) unsigned NOT NULL DEFAULT 1', + (new Column('enabled', 'tinyint', 1))->unsigned()->default(true)->sql() + ); + } + + public function test_it_returns_modified_column_copies(): void { + $column = new Column('id', 'bigint', 20); + + $this->assertSame('`id` bigint(20) NOT NULL', $column->sql()); + $this->assertSame('`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT', $column->unsigned()->autoIncrement()->sql()); + } + + public function test_auto_increment_is_idempotent(): void { + $this->assertSame( + '`id` bigint(20) NOT NULL AUTO_INCREMENT', + (new Column('id', 'bigint', 20))->autoIncrement()->autoIncrement()->sql() + ); + } + + public function test_it_escapes_string_defaults_as_sql_literals(): void { + $column = (new Column('label', 'varchar', 50))->default("customer's \\ path"); + + $this->assertSame( + "`label` varchar(50) NOT NULL DEFAULT X'637573746f6d65722773205c2070617468'", + $column->sql() + ); + $this->assertSame("X'637573746f6d65722773205c2070617468'", $column->defaultSql()); + $this->assertNull((new Column('label', 'varchar', 50))->defaultSql()); + } +} diff --git a/tests/Unit/Database/Table/CreateTableTest.php b/tests/Unit/Database/Table/CreateTableTest.php new file mode 100644 index 0000000..2d577ae --- /dev/null +++ b/tests/Unit/Database/Table/CreateTableTest.php @@ -0,0 +1,51 @@ +assertSame('foundation_example_table', $migration->id()); + } + + public function test_it_creates_missing_tables(): void { + $table = new TestTable('foundation_example_table', 'wp_example'); + $migration = new CreateTable($table); + $schema = new RecordingSchema(); + + $migration->up($schema); + + $this->assertTrue($schema->hasTable($table)); + } + + public function test_it_reconciles_existing_tables(): void { + $table = new TestTable('foundation_example_table', 'wp_example'); + $migration = new CreateTable($table); + $schema = new RecordingSchema(); + + $schema->tables['wp_example'] = true; + + $migration->up($schema); + + $this->assertSame(['createOrUpdate:wp_example'], $schema->statements); + } + + public function test_it_drops_tables_when_rolled_back(): void { + $table = new TestTable('foundation_example_table', 'wp_example'); + $migration = new CreateTable($table); + $schema = new RecordingSchema(); + + $schema->tables['wp_example'] = true; + + $migration->down($schema); + + $this->assertFalse($schema->hasTable($table)); + } +} diff --git a/tests/Unit/Database/Table/TableDefinitionTest.php b/tests/Unit/Database/Table/TableDefinitionTest.php new file mode 100644 index 0000000..bb6585e --- /dev/null +++ b/tests/Unit/Database/Table/TableDefinitionTest.php @@ -0,0 +1,166 @@ +bigIncrements('id') + ->string('status', 20) + ->text('payload') + ->dateTime('created_at') + ->index('status', 'status'); + + $this->assertCount(4, $definition->columns()); + $this->assertCount(2, $definition->indexes()); + $this->assertSame([], $definition->validationErrors()); + } + + public function test_it_defines_queue_style_columns_with_modifiers(): void { + $definition = TableDefinition::for(new TestTable('queue_table', 'wp_queue')) + ->bigIncrements('id') + ->string('queue', 255) + ->string('task_handler', 255) + ->longText('args') + ->integer('priority', 3)->nullable() + ->dateTime('run_after')->default('0000-00-00 00:00:00') + ->integer('taken')->default(0) + ->integer('done')->nullable()->default(0) + ->tinyInteger('tries')->unsigned()->default(0) + ->tinyInteger('failed', 1)->unsigned()->default(false) + ->index('done', 'done') + ->index('taken_failed', 'taken', 'failed') + ->index('taken_failed_done', 'taken', 'failed', 'done'); + + $this->assertSame([ + '`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT', + '`queue` varchar(255) NOT NULL', + '`task_handler` varchar(255) NOT NULL', + '`args` longtext NOT NULL', + '`priority` int(3) NULL', + "`run_after` datetime NOT NULL DEFAULT '0000-00-00 00:00:00'", + '`taken` int(10) NOT NULL DEFAULT 0', + '`done` int(10) NULL DEFAULT 0', + '`tries` tinyint(3) unsigned NOT NULL DEFAULT 0', + '`failed` tinyint(1) unsigned NOT NULL DEFAULT 0', + ], array_map(static fn ($column): string => $column->sql(), $definition->columns())); + + $this->assertCount(4, $definition->indexes()); + $this->assertSame([], $definition->validationErrors()); + } + + public function test_it_defines_less_common_column_helpers(): void { + $definition = TableDefinition::for(new TestTable('reports_table', 'wp_reports')) + ->bigInteger('remote_id')->unsigned() + ->string('status')->nullable()->notNull()->default('draft') + ->text('payload')->extra('COMMENT \'json payload\''); + + $this->assertSame([ + '`remote_id` bigint(20) unsigned NOT NULL', + "`status` varchar(191) NOT NULL DEFAULT 'draft'", + "`payload` text NOT NULL COMMENT 'json payload'", + ], array_map(static fn ($column): string => $column->sql(), $definition->columns())); + } + + public function test_it_defines_datetime_precision_boundaries(): void { + $definition = TableDefinition::for(new TestTable('reports_table', 'wp_reports')) + ->dateTime('seconds', 0) + ->dateTime('microseconds', 6); + + $this->assertSame([ + '`seconds` datetime NOT NULL', + '`microseconds` datetime(6) NOT NULL', + ], array_map(static fn ($column): string => $column->sql(), $definition->columns())); + } + + /** + * @dataProvider invalidDateTimePrecisionProvider + */ + #[DataProvider('invalidDateTimePrecisionProvider')] + public function test_it_rejects_invalid_datetime_precision(int $precision): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Datetime precision must be between 0 and 6.'); + + TableDefinition::for(new TestTable('reports_table', 'wp_reports')) + ->dateTime('created_at', $precision); + } + + /** + * @return array + */ + public static function invalidDateTimePrecisionProvider(): array { + return [ + 'negative' => [-1], + 'above maximum' => [7], + ]; + } + + public function test_it_rejects_indexes_that_reference_missing_columns(): void { + $definition = TableDefinition::for(new TestTable('reports_table', 'wp_reports')) + ->string('status', 20) + ->index('missing_index', 'missing'); + + $this->assertSame(['Index missing_index references missing column missing.'], $definition->validationErrors()); + + $this->expectException(InvalidArgumentException::class); + + $definition->assertValid(); + } + + public function test_it_rejects_tables_without_columns(): void { + $definition = TableDefinition::for(new TestTable('reports_table', 'wp_reports')); + + $this->assertSame(['Table reports_table does not define any columns.'], $definition->validationErrors()); + } + + public function test_it_rejects_indexes_without_columns(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('An index must define at least one column.'); + + TableDefinition::for(new TestTable('reports_table', 'wp_reports'))->index('empty_index'); + } + + public function test_it_rejects_column_modifiers_without_a_column(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('A column modifier must follow a column definition.'); + + TableDefinition::for(new TestTable('reports_table', 'wp_reports'))->nullable(); + } + + public function test_it_rejects_column_modifiers_after_index_definitions(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('A column modifier must follow a column definition.'); + + TableDefinition::for(new TestTable('reports_table', 'wp_reports')) + ->string('status') + ->index('status', 'status') + ->default('draft'); + } + + public function test_it_reports_duplicate_primary_keys(): void { + $definition = TableDefinition::for(new TestTable('reports_table', 'wp_reports')) + ->bigIncrements('id') + ->string('status') + ->primary('status'); + + $this->assertContains('A table can define only one primary key.', $definition->validationErrors()); + } + + public function test_it_reports_duplicate_index_names(): void { + $definition = TableDefinition::for(new TestTable('reports_table', 'wp_reports')) + ->bigIncrements('id') + ->string('status') + ->string('type') + ->index('status_lookup', 'status') + ->index('status_lookup', 'type'); + + $this->assertContains('Index status_lookup is defined more than once.', $definition->validationErrors()); + } +} diff --git a/tests/Unit/Database/Table/Tables/LockTableTest.php b/tests/Unit/Database/Table/Tables/LockTableTest.php new file mode 100644 index 0000000..313df6b --- /dev/null +++ b/tests/Unit/Database/Table/Tables/LockTableTest.php @@ -0,0 +1,44 @@ +rowResults = array_fill(0, 5, ['Null' => 'NO', 'Default' => null, 'Extra' => '']); + $executor = new RecordingSchemaExecutor(); + $schema = new DatabaseSchema($database, new Reconciler($database, $executor)); + $table = new LockTable('network_foundation_locks'); + + $schema->createOrUpdate($table); + + $this->assertSame(LockTable::ID, $table->id()); + $this->assertSame('network_foundation_locks', $table->name()); + $this->assertStringContainsString('CREATE TABLE `network_foundation_locks`', $executor->statements[0]); + $this->assertStringContainsString('`name` varbinary(191)', $executor->statements[0]); + $this->assertStringContainsString('`owner` varbinary(64)', $executor->statements[0]); + $this->assertStringContainsString('`expires_at` datetime(6)', $executor->statements[0]); + $this->assertStringContainsString('`created_at` datetime(6)', $executor->statements[0]); + $this->assertStringContainsString('`updated_at` datetime(6)', $executor->statements[0]); + $this->assertStringContainsString('PRIMARY KEY (`name`)', $executor->statements[0]); + $this->assertStringContainsString('KEY `expires_at`', $executor->statements[0]); + } + + public function test_it_drops_the_lock_table(): void { + $database = new FakeDatabase(); + $schema = new DatabaseSchema($database, new Reconciler($database, new RecordingSchemaExecutor())); + $table = new LockTable('network_foundation_locks'); + + $schema->drop($table); + + $this->assertSame('DROP TABLE IF EXISTS `network_foundation_locks`', $database->executed[0]); + } +} diff --git a/tests/Unit/Database/Table/Tables/MigrationTableTest.php b/tests/Unit/Database/Table/Tables/MigrationTableTest.php new file mode 100644 index 0000000..393db11 --- /dev/null +++ b/tests/Unit/Database/Table/Tables/MigrationTableTest.php @@ -0,0 +1,42 @@ +rowResults = [ + ['Null' => 'NO', 'Default' => null, 'Extra' => 'auto_increment'], + ...array_fill(0, 3, ['Null' => 'NO', 'Default' => null, 'Extra' => '']), + ]; + $executor = new RecordingSchemaExecutor(); + $schema = new DatabaseSchema($database, new Reconciler($database, $executor)); + $table = new MigrationTable('network_foundation_migrations'); + + $schema->createOrUpdate($table); + + $this->assertSame(MigrationTable::ID, $table->id()); + $this->assertSame('network_foundation_migrations', $table->name()); + $this->assertStringContainsString('CREATE TABLE `network_foundation_migrations`', $executor->statements[0]); + $this->assertStringContainsString('`migration` varbinary(191)', $executor->statements[0]); + $this->assertStringContainsString('UNIQUE KEY `migration`', $executor->statements[0]); + } + + public function test_it_drops_the_migration_table(): void { + $database = new FakeDatabase(); + $schema = new DatabaseSchema($database, new Reconciler($database, new RecordingSchemaExecutor())); + $table = new MigrationTable('network_foundation_migrations'); + + $schema->drop($table); + + $this->assertSame('DROP TABLE IF EXISTS `network_foundation_migrations`', $database->executed[0]); + } +} diff --git a/tests/Unit/Identifier/IdentifierProviderTest.php b/tests/Unit/Identifier/IdentifierProviderTest.php new file mode 100644 index 0000000..5f162fa --- /dev/null +++ b/tests/Unit/Identifier/IdentifierProviderTest.php @@ -0,0 +1,54 @@ +container->register(IdentifierProvider::class); + + $this->assertInstanceOf(RandomizerEntropy::class, $this->container->get(Entropy::class)); + $this->assertInstanceOf(SystemMillisecondClock::class, $this->container->get(MillisecondClock::class)); + $this->assertInstanceOf(UlidGenerator::class, $this->container->get(UlidGeneratorContract::class)); + $this->assertInstanceOf(UlidGenerator::class, $this->container->get(UlidGenerator::class)); + $this->assertInstanceOf(UlidValidator::class, $this->container->get(UlidValidator::class)); + } + + public function test_consumers_can_bind_ulids_as_the_default_identifier_strategy(): void { + $this->container->register(IdentifierProvider::class); + $this->container->bind(IdentifierGenerator::class, static fn (C $c): UlidGeneratorContract => $c->get(UlidGeneratorContract::class)); + + $this->assertInstanceOf(UlidGeneratorContract::class, $this->container->get(IdentifierGenerator::class)); + } + + public function test_it_does_not_bind_the_generic_identifier_contract_by_default(): void { + $this->container->register(IdentifierProvider::class); + + $this->assertFalse($this->container->has(IdentifierGenerator::class)); + } + + public function test_it_generates_ulids_with_configured_provider_dependencies(): void { + $entropy = new FixedEntropy(str_repeat("\0", 10)); + + $this->container->register(IdentifierProvider::class); + $this->container->bind(Entropy::class, $entropy); + $this->container->bind(MillisecondClock::class, new FixedMillisecondClock(1_469_918_176_385)); + + $this->assertSame('01ARYZ6S410000000000000000', $this->container->get(UlidGeneratorContract::class)->generate()); + $this->assertSame([10], $entropy->requestedLengths()); + } +} diff --git a/tests/Unit/Identifier/Ulid/RandomizerEntropyTest.php b/tests/Unit/Identifier/Ulid/RandomizerEntropyTest.php new file mode 100644 index 0000000..4f3e8e0 --- /dev/null +++ b/tests/Unit/Identifier/Ulid/RandomizerEntropyTest.php @@ -0,0 +1,15 @@ +assertSame(10, strlen((new RandomizerEntropy(new Randomizer(new Secure())))->bytes(10))); + } +} diff --git a/tests/Unit/Identifier/Ulid/SystemMillisecondClockTest.php b/tests/Unit/Identifier/Ulid/SystemMillisecondClockTest.php new file mode 100644 index 0000000..c35418e --- /dev/null +++ b/tests/Unit/Identifier/Ulid/SystemMillisecondClockTest.php @@ -0,0 +1,18 @@ +milliseconds(); + $after = (int) floor(microtime(true) * 1000); + + $this->assertGreaterThanOrEqual($before, $now); + $this->assertLessThanOrEqual($after, $now); + } +} diff --git a/tests/Unit/Identifier/Ulid/UlidGeneratorTest.php b/tests/Unit/Identifier/Ulid/UlidGeneratorTest.php new file mode 100644 index 0000000..905e7c4 --- /dev/null +++ b/tests/Unit/Identifier/Ulid/UlidGeneratorTest.php @@ -0,0 +1,96 @@ +assertInstanceOf(IdentifierGenerator::class, $generator); + $this->assertInstanceOf(UlidGeneratorContract::class, $generator); + } + + public function test_it_generates_valid_ulids(): void { + $identifier = (new UlidGenerator( + new FixedEntropy(str_repeat("\0", 10)), + new FixedMillisecondClock(0) + ))->generate(); + + $this->assertSame(26, strlen($identifier)); + $this->assertTrue((new UlidValidator())->isValid($identifier)); + } + + public function test_it_encodes_the_timestamp_before_randomness(): void { + $entropy = new FixedEntropy(str_repeat("\0", 10)); + + $generator = new UlidGenerator( + $entropy, + new FixedMillisecondClock(1_469_918_176_385) + ); + + $this->assertSame('01ARYZ6S410000000000000000', $generator->generate()); + $this->assertSame([10], $entropy->requestedLengths()); + } + + public function test_it_accepts_the_maximum_ulid_timestamp(): void { + $generator = new UlidGenerator( + new FixedEntropy(str_repeat("\0", 10)), + new FixedMillisecondClock(281_474_976_710_655) + ); + + $this->assertSame('7ZZZZZZZZZ0000000000000000', $generator->generate()); + } + + public function test_it_encodes_randomness(): void { + $generator = new UlidGenerator( + new FixedEntropy(str_repeat("\xff", 10)), + new FixedMillisecondClock(0) + ); + + $this->assertSame('0000000000ZZZZZZZZZZZZZZZZ', $generator->generate()); + } + + public function test_it_rejects_timestamps_outside_the_ulid_range(): void { + $this->expectException(OutOfRangeException::class); + $this->expectExceptionMessage('ULID timestamps must be between 0 and 281474976710655 milliseconds.'); + + (new UlidGenerator( + new FixedEntropy(str_repeat("\0", 10)), + new FixedMillisecondClock(-1) + ))->generate(); + } + + public function test_it_rejects_timestamps_above_the_ulid_range(): void { + $this->expectException(OutOfRangeException::class); + $this->expectExceptionMessage('ULID timestamps must be between 0 and 281474976710655 milliseconds.'); + + (new UlidGenerator( + new FixedEntropy(str_repeat("\0", 10)), + new FixedMillisecondClock(281_474_976_710_656) + ))->generate(); + } + + public function test_it_rejects_entropy_that_does_not_return_ten_bytes(): void { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('ULID generation requires exactly 10 random bytes.'); + + (new UlidGenerator( + new FixedEntropy(str_repeat("\0", 9)), + new FixedMillisecondClock(0) + ))->generate(); + } +} diff --git a/tests/Unit/Identifier/Ulid/UlidValidatorTest.php b/tests/Unit/Identifier/Ulid/UlidValidatorTest.php new file mode 100644 index 0000000..3e69363 --- /dev/null +++ b/tests/Unit/Identifier/Ulid/UlidValidatorTest.php @@ -0,0 +1,43 @@ + + */ + public static function invalidUlidProvider(): array { + return [ + 'empty' => ['identifier' => ''], + 'too short' => ['identifier' => '01ARYZ6S41000000000000000'], + 'too long' => ['identifier' => '01ARYZ6S4100000000000000000'], + 'lowercase' => ['identifier' => '01aryz6s410000000000000000'], + 'ambiguous i' => ['identifier' => '01ARYZ6S41000000000000000I'], + 'ambiguous l' => ['identifier' => '01ARYZ6S41000000000000000L'], + 'ambiguous o' => ['identifier' => '01ARYZ6S41000000000000000O'], + 'excluded u' => ['identifier' => '01ARYZ6S41000000000000000U'], + 'timestamp above' => ['identifier' => '81ARYZ6S410000000000000000'], + ]; + } + + public function test_it_accepts_canonical_ulids(): void { + $this->assertTrue((new UlidValidator())->isValid('01ARYZ6S410000000000000000')); + } + + public function test_it_accepts_canonical_ulids_at_the_maximum_timestamp(): void { + $this->assertTrue((new UlidValidator())->isValid('7ZZZZZZZZZ0000000000000000')); + } + + /** + * @dataProvider invalidUlidProvider + */ + #[DataProvider('invalidUlidProvider')] + public function test_it_rejects_invalid_ulids(string $identifier): void { + $this->assertFalse((new UlidValidator())->isValid($identifier)); + } +} diff --git a/tests/Unit/Lock/InMemoryLockFailureTest.php b/tests/Unit/Lock/InMemoryLockFailureTest.php new file mode 100644 index 0000000..5883f64 --- /dev/null +++ b/tests/Unit/Lock/InMemoryLockFailureTest.php @@ -0,0 +1,30 @@ +once() + ->andThrow(new RandomException('Entropy unavailable.')); + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('Unable to generate a secure lock owner.'); + + (new InMemoryLock(new MutableClock(new DateTimeImmutable('2026-01-01 00:00:00')))) + ->acquire('queue:sync', 60); + } +} diff --git a/tests/Unit/Lock/InMemoryLockTest.php b/tests/Unit/Lock/InMemoryLockTest.php new file mode 100644 index 0000000..5b76c9f --- /dev/null +++ b/tests/Unit/Lock/InMemoryLockTest.php @@ -0,0 +1,203 @@ +clock = new MutableClock(new DateTimeImmutable('2026-01-01 00:00:00')); + $this->lock = new InMemoryLock($this->clock); + } + + public function test_it_acquires_a_named_lock(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $token); + $this->assertSame('queue:sync', $token->name); + $this->assertTrue($this->lock->isAcquired('queue:sync')); + } + + public function test_it_refuses_to_acquire_a_lock_that_is_already_owned(): void { + $first = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $first); + $this->assertNull($this->lock->acquire('queue:sync', 60)); + } + + public function test_it_tracks_named_locks_independently(): void { + $sync = $this->lock->acquire('queue:sync', 60); + $cleanup = $this->lock->acquire('queue:cleanup', 60); + + $this->assertInstanceOf(LockToken::class, $sync); + $this->assertInstanceOf(LockToken::class, $cleanup); + + $this->assertTrue($this->lock->release($sync)); + $this->assertFalse($this->lock->isAcquired('queue:sync')); + $this->assertTrue($this->lock->isAcquired('queue:cleanup')); + } + + public function test_it_expires_named_locks_independently(): void { + $sync = $this->lock->acquire('queue:sync', 30); + $cleanup = $this->lock->acquire('queue:cleanup', 90); + + $this->clock->advance(31); + + $this->assertInstanceOf(LockToken::class, $sync); + $this->assertInstanceOf(LockToken::class, $cleanup); + $this->assertFalse($this->lock->isAcquired('queue:sync')); + $this->assertTrue($this->lock->isAcquired('queue:cleanup')); + } + + public function test_it_allows_a_lock_to_be_acquired_after_it_expires(): void { + $first = $this->lock->acquire('queue:sync', 60); + + $this->clock->advance(61); + + $second = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $first); + $this->assertInstanceOf(LockToken::class, $second); + $this->assertNotSame($first->owner, $second->owner); + } + + public function test_it_treats_a_lock_as_expired_at_the_expiration_boundary(): void { + $first = $this->lock->acquire('queue:sync', 60); + + $this->clock->advance(60); + + $second = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $first); + $this->assertFalse($this->lock->release($first)); + $this->assertInstanceOf(LockToken::class, $second); + $this->assertNotSame($first->owner, $second->owner); + } + + public function test_it_releases_a_lock_with_the_matching_token(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $token); + $this->assertTrue($this->lock->release($token)); + $this->assertFalse($this->lock->isAcquired('queue:sync')); + } + + public function test_it_refuses_to_release_a_lock_with_a_different_token(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $token); + $this->assertFalse($this->lock->release(new LockToken( + name: 'queue:sync', + owner: 'other-owner', + expiresAt: $token->expiresAt + ))); + $this->assertTrue($this->lock->isAcquired('queue:sync')); + } + + public function test_it_does_not_release_an_expired_lock(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->clock->advance(61); + + $this->assertInstanceOf(LockToken::class, $token); + $this->assertFalse($this->lock->release($token)); + $this->assertFalse($this->lock->isAcquired('queue:sync')); + } + + public function test_it_refreshes_a_lock_with_the_matching_token(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->clock->advance(30); + + $this->assertInstanceOf(LockToken::class, $token); + + $refreshed = $this->lock->refresh($token, 120); + + $this->assertInstanceOf(LockToken::class, $refreshed); + $this->assertSame($token->name, $refreshed->name); + $this->assertSame($token->owner, $refreshed->owner); + $this->assertSame('2026-01-01 00:02:30', $refreshed->expiresAt->format('Y-m-d H:i:s')); + + $this->clock->advance(31); + + $this->assertTrue($this->lock->isAcquired('queue:sync')); + + $this->clock->advance(89); + + $this->assertFalse($this->lock->isAcquired('queue:sync')); + } + + public function test_it_refuses_to_refresh_a_lock_with_a_different_token(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $token); + $this->assertNull($this->lock->refresh(new LockToken( + name: 'queue:sync', + owner: 'other-owner', + expiresAt: $token->expiresAt + ), 120)); + } + + public function test_it_refuses_to_refresh_an_expired_lock(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->clock->advance(61); + + $this->assertInstanceOf(LockToken::class, $token); + $this->assertNull($this->lock->refresh($token, 120)); + $this->assertFalse($this->lock->isAcquired('queue:sync')); + } + + public function test_it_rejects_an_empty_lock_name(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock name cannot be empty.'); + + $this->lock->acquire('', 60); + } + + public function test_it_rejects_an_invalid_ttl(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock TTL must be greater than zero seconds.'); + + $this->lock->acquire('queue:sync', 0); + } + + public function test_it_rejects_an_invalid_ttl_when_the_lock_is_already_owned(): void { + $this->lock->acquire('queue:sync', 60); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock TTL must be greater than zero seconds.'); + + $this->lock->acquire('queue:sync', 0); + } + + public function test_it_rejects_an_invalid_ttl_when_refreshing_a_lock(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $token); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock TTL must be greater than zero seconds.'); + + $this->lock->refresh($token, 0); + } + + public function test_it_rejects_a_ttl_that_cannot_be_represented(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock TTL cannot be represented.'); + + $this->lock->acquire('queue:sync', 1_000_000_000_000); + } +} diff --git a/tests/Unit/Lock/LockTokenTest.php b/tests/Unit/Lock/LockTokenTest.php new file mode 100644 index 0000000..bd96449 --- /dev/null +++ b/tests/Unit/Lock/LockTokenTest.php @@ -0,0 +1,82 @@ +assertFalse($token->isExpired(new DateTimeImmutable('2026-01-01 00:00:59'))); + $this->assertTrue($token->isExpired(new DateTimeImmutable('2026-01-01 00:01:00'))); + } + + public function test_it_matches_tokens_for_the_same_lock_owner(): void { + $token = new LockToken( + name: 'queue:sync', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + + $this->assertTrue($token->matches(new LockToken( + name: 'queue:sync', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:02:00') + ))); + $this->assertFalse($token->matches(new LockToken( + name: 'queue:sync', + owner: 'other-owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ))); + $this->assertFalse($token->matches(new LockToken( + name: 'queue:cleanup', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ))); + } + + public function test_it_changes_expiration_with_the_same_lock_name_and_owner(): void { + $token = new LockToken( + name: 'queue:sync', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + + $refreshed = $token->withExpiration(new DateTimeImmutable('2026-01-01 00:02:00')); + + $this->assertSame($token->name, $refreshed->name); + $this->assertSame($token->owner, $refreshed->owner); + $this->assertSame('2026-01-01 00:02:00', $refreshed->expiresAt->format('Y-m-d H:i:s')); + } + + public function test_it_rejects_an_empty_lock_name(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock name cannot be empty.'); + + new LockToken( + name: '', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + } + + public function test_it_rejects_an_empty_owner(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock owner cannot be empty.'); + + new LockToken( + name: 'queue:sync', + owner: '', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + } +} diff --git a/tests/Unit/Lock/SystemClockTest.php b/tests/Unit/Lock/SystemClockTest.php new file mode 100644 index 0000000..03ec926 --- /dev/null +++ b/tests/Unit/Lock/SystemClockTest.php @@ -0,0 +1,19 @@ +now(); + $after = new DateTimeImmutable(); + + $this->assertGreaterThanOrEqual($before, $now); + $this->assertLessThanOrEqual($after, $now); + } +} diff --git a/tests/Unit/LockRedis/LockRedisProviderTest.php b/tests/Unit/LockRedis/LockRedisProviderTest.php new file mode 100644 index 0000000..2c8b323 --- /dev/null +++ b/tests/Unit/LockRedis/LockRedisProviderTest.php @@ -0,0 +1,44 @@ +container->get(Dot::class)->set('lock.redis.prefix', 'provider:lock:'); + $this->container->bind(Connection::class, $connection); + $this->container->register(LockRedisProvider::class); + + $lock = $this->container->get(RedisLock::class); + $token = $lock->acquire('queue:sync', 60); + + $this->assertSame($lock, $this->container->get(RedisLock::class)); + $this->assertNotNull($token); + $this->assertSame(['provider:lock:queue:sync'], $connection->evaluateCalls[0]['keys']); + } + + public function test_it_does_not_bind_the_generic_lock_contract(): void { + $this->container->get(Dot::class)->set('lock.redis.prefix', 'provider:lock:'); + $this->container->register(LockRedisProvider::class); + + $this->assertFalse($this->container->has(Lock::class)); + } + + public function test_it_requires_an_explicit_prefix(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The lock.redis.prefix configuration value must be a non-empty string.'); + + $this->container->register(LockRedisProvider::class); + } +} diff --git a/tests/Unit/LockRedis/PhpRedisConnectionTest.php b/tests/Unit/LockRedis/PhpRedisConnectionTest.php new file mode 100644 index 0000000..3bece52 --- /dev/null +++ b/tests/Unit/LockRedis/PhpRedisConnectionTest.php @@ -0,0 +1,47 @@ +mock(Redis::class); + $redis->shouldReceive('clearLastError')->once()->andReturnTrue(); + $redis->shouldReceive('exists')->once()->andReturnFalse(); + $redis->shouldReceive('getLastError')->once()->andReturnNull(); + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('PhpRedis returned an unexpected EXISTS response.'); + + (new PhpRedisConnection($redis))->exists('lock'); + } + + public function test_it_wraps_phpredis_exceptions(): void { + $redis = $this->mock(Redis::class); + $redis->shouldReceive('clearLastError')->once()->andReturnTrue(); + $redis->shouldReceive('exists')->once()->andThrow(new RedisException('connection lost')); + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('PhpRedis could not execute the lock operation.'); + + (new PhpRedisConnection($redis))->exists('lock'); + } + + public function test_it_rejects_phpredis_command_errors(): void { + $redis = $this->mock(Redis::class); + $redis->shouldReceive('clearLastError')->once()->andReturnTrue(); + $redis->shouldReceive('exists')->once()->andReturnFalse(); + $redis->shouldReceive('getLastError')->once()->andReturn('ERR connection lost'); + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('PhpRedis could not execute the lock operation: ERR connection lost'); + + (new PhpRedisConnection($redis))->exists('lock'); + } +} diff --git a/tests/Unit/LockRedis/PredisConnectionTest.php b/tests/Unit/LockRedis/PredisConnectionTest.php new file mode 100644 index 0000000..08d2113 --- /dev/null +++ b/tests/Unit/LockRedis/PredisConnectionTest.php @@ -0,0 +1,65 @@ +clientReturning(null)); + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('Predis returned an unexpected EVAL response.'); + + $connection->evaluate('return 1', [], []); + } + + public function test_it_rejects_non_integer_exists_responses(): void { + $connection = new PredisConnection($this->clientReturning(null)); + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('Predis returned an unexpected EXISTS response.'); + + $connection->exists('lock'); + } + + public function test_it_wraps_predis_exceptions(): void { + $client = $this->mock(ClientInterface::class); + $command = $this->mock(CommandInterface::class); + + $client->shouldReceive('createCommand')->once()->andReturn($command); + $client->shouldReceive('executeCommand')->once()->with($command)->andThrow(new ClientException('connection lost')); + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('Predis could not execute the lock operation.'); + + (new PredisConnection($client))->exists('lock'); + } + + public function test_it_rejects_predis_error_responses(): void { + $error = $this->mock(ErrorInterface::class); + $error->shouldReceive('getMessage')->once()->andReturn('ERR connection lost'); + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('Predis could not execute the lock operation: ERR connection lost'); + + (new PredisConnection($this->clientReturning($error)))->exists('lock'); + } + + private function clientReturning(mixed $result): ClientInterface { + $client = $this->mock(ClientInterface::class); + $command = $this->mock(CommandInterface::class); + + $client->shouldReceive('createCommand')->once()->andReturn($command); + $client->shouldReceive('executeCommand')->once()->with($command)->andReturn($result); + + return $client; + } +} diff --git a/tests/Unit/LockRedis/RedisLockFailureTest.php b/tests/Unit/LockRedis/RedisLockFailureTest.php new file mode 100644 index 0000000..e8a9190 --- /dev/null +++ b/tests/Unit/LockRedis/RedisLockFailureTest.php @@ -0,0 +1,40 @@ +once() + ->andThrow(new RandomException('Entropy unavailable.')); + $connection = new RecordingConnection(); + $lock = new RedisLock( + $connection, + new MutableClock(new DateTimeImmutable('2026-01-01 00:00:00')), + 'tests:lock:' + ); + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('Unable to generate a secure lock owner.'); + + try { + $lock->acquire('queue:sync', 60); + } finally { + $this->assertSame([], $connection->evaluateCalls); + } + } +} diff --git a/tests/Unit/LockRedis/RedisLockTest.php b/tests/Unit/LockRedis/RedisLockTest.php new file mode 100644 index 0000000..0ccf478 --- /dev/null +++ b/tests/Unit/LockRedis/RedisLockTest.php @@ -0,0 +1,176 @@ +clock = new MutableClock(new DateTimeImmutable('2026-01-01 00:00:00')); + $this->connection = new RecordingConnection(); + $this->lock = new RedisLock($this->connection, $this->clock, 'tests:lock:'); + } + + public function test_it_acquires_a_prefixed_lock_with_a_conservative_expiration(): void { + $token = $this->lock->acquire('queue:sync', 60); + + $this->assertInstanceOf(LockToken::class, $token); + $this->assertSame('queue:sync', $token->name); + $this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $token->owner); + $this->assertSame('2026-01-01 00:01:00', $token->expiresAt->format('Y-m-d H:i:s')); + $this->assertSame([[ + 'script' => $this->connection->evaluateCalls[0]['script'], + 'keys' => ['tests:lock:queue:sync'], + 'arguments' => [$token->owner, 60], + ]], $this->connection->evaluateCalls); + $this->assertStringContainsString("redis.call('SET'", $this->connection->evaluateCalls[0]['script']); + } + + public function test_it_returns_null_when_the_lock_is_contended(): void { + $this->connection->evaluateResult = 0; + + $this->assertNull($this->lock->acquire('queue:sync', 60)); + } + + public function test_it_rejects_an_unexpected_acquisition_result(): void { + $this->connection->evaluateResult = 2; + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('Redis returned an unexpected acquisition result.'); + + $this->lock->acquire('queue:sync', 60); + } + + public function test_it_releases_only_the_matching_owner(): void { + $token = $this->token(); + + $this->assertTrue($this->lock->release($token)); + $this->assertSame(['tests:lock:queue:sync'], $this->connection->evaluateCalls[0]['keys']); + $this->assertSame(['owner'], $this->connection->evaluateCalls[0]['arguments']); + + $this->connection->evaluateResult = 0; + + $this->assertFalse($this->lock->release($token)); + } + + public function test_it_refreshes_only_the_matching_owner(): void { + $token = $this->token(); + + $this->clock->advance(30); + + $refreshed = $this->lock->refresh($token, 120); + + $this->assertInstanceOf(LockToken::class, $refreshed); + $this->assertSame('2026-01-01 00:02:30', $refreshed->expiresAt->format('Y-m-d H:i:s')); + $this->assertSame(['tests:lock:queue:sync'], $this->connection->evaluateCalls[0]['keys']); + $this->assertSame(['owner', 120], $this->connection->evaluateCalls[0]['arguments']); + + $this->connection->evaluateResult = 0; + + $this->assertNull($this->lock->refresh($token, 120)); + } + + public function test_it_reports_prefixed_lock_existence(): void { + $this->connection->existsResult = true; + + $this->assertTrue($this->lock->isAcquired('queue:sync')); + $this->assertSame(['tests:lock:queue:sync'], $this->connection->existsCalls); + } + + public function test_it_rejects_an_empty_prefix(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Redis lock prefix cannot be empty.'); + + new RedisLock($this->connection, $this->clock, ''); + } + + public function test_it_rejects_an_empty_lock_name(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock name cannot be empty.'); + + $this->lock->acquire('', 60); + } + + public function test_it_rejects_an_invalid_acquisition_ttl(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock TTL must be greater than zero seconds.'); + + $this->lock->acquire('queue:sync', 0); + } + + public function test_it_rejects_an_invalid_refresh_ttl(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock TTL must be greater than zero seconds.'); + + $this->lock->refresh($this->token(), 0); + } + + public function test_it_does_not_acquire_when_the_ttl_cannot_be_represented(): void { + try { + $this->lock->acquire('queue:sync', 1_000_000_000_000); + $this->fail('Expected an invalid TTL exception.'); + } catch (InvalidArgumentException $exception) { + $this->assertSame('Lock TTL cannot be represented.', $exception->getMessage()); + $this->assertSame([], $this->connection->evaluateCalls); + } + } + + public function test_it_does_not_refresh_when_the_ttl_cannot_be_represented(): void { + try { + $this->lock->refresh($this->token(), 1_000_000_000_000); + $this->fail('Expected an invalid TTL exception.'); + } catch (InvalidArgumentException $exception) { + $this->assertSame('Lock TTL cannot be represented.', $exception->getMessage()); + $this->assertSame([], $this->connection->evaluateCalls); + } + } + + public function test_it_rejects_an_empty_name_when_checking_existence(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Lock name cannot be empty.'); + + $this->lock->isAcquired(''); + } + + public function test_it_rejects_an_unexpected_release_result(): void { + $this->connection->evaluateResult = 2; + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('Redis returned an unexpected release result.'); + + $this->lock->release($this->token()); + } + + public function test_it_rejects_an_unexpected_refresh_result(): void { + $this->connection->evaluateResult = 2; + + $this->expectException(LockUnavailableException::class); + $this->expectExceptionMessage('Redis returned an unexpected refresh result.'); + + $this->lock->refresh($this->token(), 60); + } + + private function token(): LockToken { + return new LockToken( + name: 'queue:sync', + owner: 'owner', + expiresAt: new DateTimeImmutable('2026-01-01 00:01:00') + ); + } +} diff --git a/tests/Unit/Shutdown/ResponseFinishingRunnerTest.php b/tests/Unit/Shutdown/ResponseFinishingRunnerTest.php new file mode 100644 index 0000000..e205dc1 --- /dev/null +++ b/tests/Unit/Shutdown/ResponseFinishingRunnerTest.php @@ -0,0 +1,92 @@ +markTestSkipped('Native response-finishing functions cannot be replaced by test fixtures.'); + } + } + + #[RunInSeparateProcess] + public function test_it_finishes_the_response_and_runs_shutdown_tasks_once(): void { + require dirname(__DIR__, 2) . '/Support/Fixtures/Shutdown/litespeed-finish-request.php'; + + $GLOBALS['foundation_shutdown_calls'] = []; + + $runner = $this->runner(); + + $runner->terminate(); + $runner->terminate(); + + $this->assertSame(['litespeed', 'task'], $GLOBALS['foundation_shutdown_calls']); + + unset($GLOBALS['foundation_shutdown_calls']); + } + + #[RunInSeparateProcess] + public function test_it_prefers_fastcgi_response_finishing(): void { + require dirname(__DIR__, 2) . '/Support/Fixtures/Shutdown/finish-request-functions.php'; + + $GLOBALS['foundation_shutdown_calls'] = []; + + $this->runner()->terminate(); + + $this->assertSame(['fastcgi', 'task'], $GLOBALS['foundation_shutdown_calls']); + + unset($GLOBALS['foundation_shutdown_calls']); + } + + #[RunInSeparateProcess] + public function test_a_response_finishing_failure_does_not_prevent_shutdown_tasks(): void { + require dirname(__DIR__, 2) . '/Support/Fixtures/Shutdown/finish-request-functions.php'; + + $GLOBALS['foundation_shutdown_calls'] = []; + $GLOBALS['foundation_shutdown_fastcgi_failure'] = true; + + $this->runner()->terminate(); + + $this->assertSame(['fastcgi', 'litespeed', 'task'], $GLOBALS['foundation_shutdown_calls']); + + unset( + $GLOBALS['foundation_shutdown_calls'], + $GLOBALS['foundation_shutdown_fastcgi_failure'] + ); + } + + #[RunInSeparateProcess] + public function test_it_falls_back_when_fastcgi_does_not_finish_the_response(): void { + require dirname(__DIR__, 2) . '/Support/Fixtures/Shutdown/finish-request-functions.php'; + + $GLOBALS['foundation_shutdown_calls'] = []; + $GLOBALS['foundation_shutdown_fastcgi_false'] = true; + + $this->runner()->terminate(); + + $this->assertSame(['fastcgi', 'litespeed', 'task'], $GLOBALS['foundation_shutdown_calls']); + + unset( + $GLOBALS['foundation_shutdown_calls'], + $GLOBALS['foundation_shutdown_fastcgi_false'] + ); + } + + private function runner(): ResponseFinishingRunner { + return new ResponseFinishingRunner(new ShutdownRunner([ + new ShutdownTask(new CallbackTerminable(static function (): void { + $GLOBALS['foundation_shutdown_calls'][] = 'task'; + })), + ])); + } +} diff --git a/tests/Unit/Shutdown/ShutdownRunnerTest.php b/tests/Unit/Shutdown/ShutdownRunnerTest.php new file mode 100644 index 0000000..fd77909 --- /dev/null +++ b/tests/Unit/Shutdown/ShutdownRunnerTest.php @@ -0,0 +1,154 @@ +recordingTask($calls, 'last', 100), + $this->recordingTask($calls, 'second', 10), + $this->recordingTask($calls, 'third', 10), + $this->recordingTask($calls, 'first', 0), + ]); + + $runner->terminate(); + + $this->assertSame(['first', 'second', 'third', 'last'], $calls); + } + + public function test_it_runs_each_task_only_once(): void { + $calls = []; + $runner = new ShutdownRunner([$this->recordingTask($calls, 'task')]); + + $runner->terminate(); + $runner->terminate(); + + $this->assertSame(['task'], $calls); + } + + public function test_it_is_safe_to_invoke_recursively(): void { + $calls = []; + $runner = new ShutdownRunner(); + + $recursive = new CallbackTerminable(static function () use (&$calls, &$runner): void { + $calls[] = 'recursive'; + $runner->terminate(); + }); + + $runner = new ShutdownRunner([ + new ShutdownTask($recursive), + $this->recordingTask($calls, 'next'), + ]); + + $runner->terminate(); + + $this->assertSame(['recursive', 'next'], $calls); + } + + public function test_a_failed_task_does_not_prevent_later_tasks(): void { + $calls = []; + + $failing = new CallbackTerminable(static function () use (&$calls): void { + $calls[] = 'failed'; + + throw new Error('Expected test failure.'); + }); + + $runner = new ShutdownRunner([ + new ShutdownTask($failing), + $this->recordingTask($calls, 'completed'), + ]); + + $runner->terminate(); + + $this->assertSame(['failed', 'completed'], $calls); + } + + public function test_it_logs_task_execution_and_failures_when_a_logger_is_available(): void { + $handler = new TestHandler(); + $logger = new Logger('shutdown', [$handler]); + $failure = new Error('Expected test failure.', 42); + $failing = new CallbackTerminable(static function () use ($failure): void { + throw $failure; + }); + + $runner = new ShutdownRunner([ + new ShutdownTask($failing, 10), + ], $logger); + + $runner->terminate(); + + $records = $handler->getRecords(); + + $this->assertCount(3, $records); + $this->assertSame('Running shutdown tasks.', $records[0]['message']); + $this->assertSame(['task_count' => 1], $records[0]['context']); + $this->assertSame('Running shutdown task.', $records[1]['message']); + $this->assertSame([ + 'task' => CallbackTerminable::class, + 'priority' => 10, + ], $records[1]['context']); + $this->assertSame('Shutdown task failed.', $records[2]['message']); + $this->assertSame([ + 'task' => CallbackTerminable::class, + 'priority' => 10, + 'exception' => $failure, + ], $records[2]['context']); + } + + public function test_a_failed_logger_does_not_prevent_termination_work(): void { + $calls = []; + $logger = $this->createMock(LoggerInterface::class); + + $logger->method('log')->willThrowException(new Error('Expected logger failure.')); + + $runner = new ShutdownRunner([ + $this->recordingTask($calls, 'completed'), + ], $logger); + + $runner->terminate(); + + $this->assertSame(['completed'], $calls); + } + + public function test_it_accepts_an_empty_task_list(): void { + $runner = new ShutdownRunner(); + + $runner->terminate(); + $runner->terminate(); + + $this->addToAssertionCount(1); + } + + public function test_it_rejects_invalid_task_contributions(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Shutdown tasks must be instances of ShutdownTask.'); + + $runner = new ShutdownRunner(['invalid']); + } + + /** + * @param list $calls + */ + private function recordingTask(array &$calls, string $name, int $priority = 0): ShutdownTask { + return new ShutdownTask( + new CallbackTerminable(static function () use (&$calls, $name): void { + $calls[] = $name; + }), + $priority + ); + } +} diff --git a/tests/Unit/View/PhpViewTest.php b/tests/Unit/View/PhpViewTest.php new file mode 100644 index 0000000..df33fa1 --- /dev/null +++ b/tests/Unit/View/PhpViewTest.php @@ -0,0 +1,216 @@ +data_dir('View/default')); + + $this->assertSame( + '

Hello, Foundation

' . PHP_EOL, + $view->render('greeting', [ + 'greeting' => 'Hello', + 'name' => 'Foundation', + ]) + ); + } + + public function test_it_escapes_template_data_in_the_template(): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->assertSame( + '

<strong>Hello</strong>, Foundation

' . PHP_EOL, + $view->render('greeting', [ + 'greeting' => 'Hello', + 'name' => 'Foundation', + ]) + ); + } + + public function test_it_renders_a_view_from_a_nested_directory(): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->assertSame('

Product summary

' . PHP_EOL, $view->render('admin/product-summary')); + } + + public function test_it_returns_a_new_renderer_for_a_runtime_directory_without_mutating_the_original(): void { + $view = new PhpView($this->data_dir('View/default')); + $runtimeView = $view->withDirectory($this->data_dir('View/runtime')); + + $this->assertNotSame($view, $runtimeView); + $this->assertSame('

Runtime directory

' . PHP_EOL, $runtimeView->render('greeting')); + $this->assertSame( + '

Hello, Foundation

' . PHP_EOL, + $view->render('greeting', ['greeting' => 'Hello', 'name' => 'Foundation']) + ); + } + + public function test_view_data_cannot_replace_the_resolved_view_path(): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->assertSame( + 'internal-variable.php', + $view->render('internal-variable', [ + 'foundationViewPath' => $this->data_dir('View/outside.php'), + ]) + ); + } + + public function test_it_restores_the_output_buffer_when_a_view_throws(): void { + $view = new PhpView($this->data_dir('View/default')); + $bufferLevel = ob_get_level(); + + try { + $view->render('throws'); + $this->fail('Expected the view exception to be propagated.'); + } catch (RuntimeException $exception) { + $this->assertSame('View rendering failed.', $exception->getMessage()); + } + + $this->assertSame($bufferLevel, ob_get_level()); + } + + public function test_it_rejects_and_cleans_up_an_unclosed_view_buffer(): void { + $view = new PhpView($this->data_dir('View/default')); + $bufferLevel = ob_get_level(); + + try { + $view->render('unclosed-buffer'); + $this->fail('Expected unbalanced output buffering to be rejected.'); + } catch (RuntimeException $exception) { + $this->assertSame('The view "unclosed-buffer" must leave output buffering unchanged.', $exception->getMessage()); + } + + $this->assertSame($bufferLevel, ob_get_level()); + } + + public function test_it_allows_balanced_buffers_owned_by_the_view(): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->assertSame('Balanced view output.', $view->render('balanced-buffer')); + } + + public function test_it_does_not_close_a_caller_buffer_when_the_view_closes_its_rendering_buffer(): void { + $view = new PhpView($this->data_dir('View/default')); + $bufferLevel = ob_get_level(); + + ob_start(); + + try { + $view->render('closes-buffer'); + $this->fail('Expected an unexpectedly closed rendering buffer to be rejected.'); + } catch (RuntimeException $exception) { + $this->assertSame('The view "closes-buffer" must leave output buffering unchanged.', $exception->getMessage()); + $this->assertSame($bufferLevel + 1, ob_get_level()); + } finally { + while (ob_get_level() > $bufferLevel) { + ob_end_clean(); + } + } + + $this->assertSame($bufferLevel, ob_get_level()); + } + + public function test_it_rejects_a_same_depth_replacement_for_its_rendering_buffer(): void { + $view = new PhpView($this->data_dir('View/default')); + $bufferLevel = ob_get_level(); + + try { + $view->render('replaces-buffer'); + $this->fail('Expected a replaced rendering buffer to be rejected.'); + } catch (RuntimeException $exception) { + $this->assertSame('The view "replaces-buffer" must leave output buffering unchanged.', $exception->getMessage()); + } + + $this->assertSame($bufferLevel, ob_get_level()); + } + + public function test_it_rejects_flushing_its_rendering_buffer_without_leaking_output(): void { + $view = new PhpView($this->data_dir('View/default')); + $bufferLevel = ob_get_level(); + + ob_start(); + + try { + $view->render('flushes-buffer'); + $this->fail('Expected a flushed rendering buffer to be rejected.'); + } catch (RuntimeException $exception) { + $this->assertSame('The view "flushes-buffer" must leave output buffering unchanged.', $exception->getMessage()); + $this->assertSame('', ob_get_contents()); + } finally { + while (ob_get_level() > $bufferLevel) { + ob_end_clean(); + } + } + + $this->assertSame($bufferLevel, ob_get_level()); + } + + public function test_it_rejects_an_invalid_view_directory(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must exist and be readable'); + + new PhpView($this->data_dir('View/missing')); + } + + public function test_it_reports_a_missing_view(): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->expectException(ViewNotFoundException::class); + $this->expectExceptionMessage('The view "missing" could not be found'); + + $view->render('missing'); + } + + /** + * @dataProvider invalid_view_names + */ + #[\PHPUnit\Framework\Attributes\DataProvider('invalid_view_names')] + public function test_it_rejects_unsafe_view_names(string $name): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->expectException(InvalidArgumentException::class); + + $view->render($name); + } + + /** + * @return array + */ + public static function invalid_view_names(): array { + return [ + 'empty' => ['name' => ''], + 'null byte' => ['name' => "greeting\0ignored"], + 'absolute Unix' => ['name' => '/tmp/view'], + 'absolute Windows' => ['name' => 'C:\\tmp\\view'], + 'parent traversal' => ['name' => '../outside'], + 'nested traversal' => ['name' => 'nested/../../outside'], + ]; + } + + public function test_it_rejects_a_symlink_that_resolves_outside_the_view_directory(): void { + $directory = $this->prepare_temp_dir('view'); + $root = $directory . '/root'; + $outside = $directory . '/outside.php'; + + mkdir($root); + file_put_contents($outside, 'Outside'); + + if (! symlink($outside, $root . '/linked.php')) { + $this->markTestSkipped('The test environment cannot create symbolic links.'); + } + + $view = new PhpView($root); + + $this->expectException(ViewNotFoundException::class); + + $view->render('linked'); + } +} diff --git a/tests/Unit/View/ViewContractTest.php b/tests/Unit/View/ViewContractTest.php new file mode 100644 index 0000000..317fd38 --- /dev/null +++ b/tests/Unit/View/ViewContractTest.php @@ -0,0 +1,30 @@ +assertSame( + '{"view":"product-summary","data":{"count":3}}', + $view->render('product-summary', ['count' => 3]) + ); + } + + public function test_a_directory_aware_adapter_may_return_another_implementation(): void { + $view = new PhpView($this->data_dir('View/default')); + $adapter = new DelegatingDirectoryView($view); + $runtimeView = $adapter->withDirectory($this->data_dir('View/runtime')); + + $this->assertInstanceOf(PhpView::class, $runtimeView); + $this->assertNotSame($view, $runtimeView); + $this->assertSame('

Runtime directory

' . PHP_EOL, $runtimeView->render('greeting')); + } +} diff --git a/tests/Unit/View/ViewProviderTest.php b/tests/Unit/View/ViewProviderTest.php new file mode 100644 index 0000000..1018927 --- /dev/null +++ b/tests/Unit/View/ViewProviderTest.php @@ -0,0 +1,37 @@ +container->get(Dot::class)->set('view.directory', $this->data_dir('View/default')); + $this->container->register(ViewProvider::class); + + $view = $this->container->get(View::class); + + $this->assertInstanceOf(PhpView::class, $view); + $this->assertSame($view, $this->container->get(View::class)); + $this->assertSame($view, $this->container->get(DirectoryAwareView::class)); + $this->assertSame($view, $this->container->get(PhpView::class)); + $this->assertSame( + '

Hello, Foundation

' . PHP_EOL, + $view->render('greeting', ['greeting' => 'Hello', 'name' => 'Foundation']) + ); + } + + public function test_it_rejects_missing_view_configuration(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('view.directory configuration value must be a non-empty string'); + + $this->container->register(ViewProvider::class); + } +} diff --git a/tests/Unit/WPCli/CommandTest.php b/tests/Unit/WPCli/CommandTest.php index a04c395..dbf9bba 100644 --- a/tests/Unit/WPCli/CommandTest.php +++ b/tests/Unit/WPCli/CommandTest.php @@ -6,12 +6,13 @@ use PHPUnit\Framework\Attributes\RunInSeparateProcess; use StellarWP\Foundation\Tests\Support\Fixtures\WPCli\TestCommand; use StellarWP\Foundation\Tests\TestCase; +use StellarWP\Foundation\WPCli\ValueObjects\CommandPrefix; use WP_CLI; final class CommandTest extends TestCase { public function test_it_runs_a_foundation_wp_cli_command(): void { - $command = new TestCommand($this->container, 'foundation'); + $command = new TestCommand($this->container, new CommandPrefix('foundation')); $this->assertSame(0, $command->runCommand(['value'], ['flag' => true])); $this->assertSame(['value'], $command->args); @@ -28,7 +29,7 @@ public function test_it_runs_a_foundation_wp_cli_command(): void { } public function test_it_asks_for_normalized_input(): void { - $command = new TestCommand($this->container, 'foundation'); + $command = new TestCommand($this->container, new CommandPrefix('foundation')); $result = $command->promptWithInput('Continue?', 'YES' . PHP_EOL); @@ -37,7 +38,7 @@ public function test_it_asks_for_normalized_input(): void { } public function test_it_exposes_default_input_and_output_streams(): void { - $command = new TestCommand($this->container, 'foundation'); + $command = new TestCommand($this->container, new CommandPrefix('foundation')); $this->assertIsResource($command->defaultInput()); $this->assertIsResource($command->defaultOutput()); @@ -58,7 +59,7 @@ public function test_it_registers_with_wp_cli_using_the_prefixed_command_name(): require_once $wpCliRoot . '/php/utils.php'; - $command = new TestCommand($this->container, 'foundation'); + $command = new TestCommand($this->container, new CommandPrefix('foundation')); $command->register(); $deferredAdditions = WP_CLI::get_deferred_additions(); diff --git a/tests/Unit/WPCli/ValueObjects/CommandPrefixTest.php b/tests/Unit/WPCli/ValueObjects/CommandPrefixTest.php new file mode 100644 index 0000000..56fa360 --- /dev/null +++ b/tests/Unit/WPCli/ValueObjects/CommandPrefixTest.php @@ -0,0 +1,39 @@ +assertSame('your-plugin', (new CommandPrefix('your-plugin'))->value); + } + + /** + * @dataProvider invalidPrefixes + */ + #[DataProvider('invalidPrefixes')] + public function test_it_rejects_an_invalid_prefix(string $prefix): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('cannot be empty or contain surrounding whitespace'); + + new CommandPrefix($prefix); + } + + /** + * @return iterable + */ + public static function invalidPrefixes(): iterable { + yield 'empty' => ['']; + + yield 'spaces' => [' ']; + + yield 'leading whitespace' => [' your-plugin']; + + yield 'trailing whitespace' => ['your-plugin ']; + } +} diff --git a/tests/WPUnitSupport/WPTestCase.php b/tests/WPUnitSupport/WPTestCase.php index 910d806..92be16d 100644 --- a/tests/WPUnitSupport/WPTestCase.php +++ b/tests/WPUnitSupport/WPTestCase.php @@ -2,7 +2,11 @@ namespace StellarWP\Foundation\Tests\WPUnitSupport; +use Adbar\Dot; use lucatume\WPBrowser\TestCase\WPTestCase as CodeceptionWPTestCase; +use StellarWP\ContainerContract\ContainerInterface; +use StellarWP\Foundation\Container\ContainerAdapter; +use StellarWP\Foundation\Container\Contracts\Container; /** * Base test case for WordPress integration tests. @@ -12,4 +16,14 @@ */ abstract class WPTestCase extends CodeceptionWPTestCase { + protected Container $container; + + protected function setUp(): void { + parent::setUp(); + + $this->container = new ContainerAdapter(new \lucatume\DI52\Container()); + $this->container->bind(Container::class, $this->container); + $this->container->bind(ContainerInterface::class, $this->container); + $this->container->singleton(Dot::class, new Dot(require dirname(__DIR__) . '/config.php')); + } } diff --git a/tests/_data/View/default/admin/product-summary.php b/tests/_data/View/default/admin/product-summary.php new file mode 100644 index 0000000..ce78839 --- /dev/null +++ b/tests/_data/View/default/admin/product-summary.php @@ -0,0 +1 @@ +

Product summary

diff --git a/tests/_data/View/default/balanced-buffer.php b/tests/_data/View/default/balanced-buffer.php new file mode 100644 index 0000000..1f2e065 --- /dev/null +++ b/tests/_data/View/default/balanced-buffer.php @@ -0,0 +1,7 @@ +

,

diff --git a/tests/_data/View/default/internal-variable.php b/tests/_data/View/default/internal-variable.php new file mode 100644 index 0000000..43e0588 --- /dev/null +++ b/tests/_data/View/default/internal-variable.php @@ -0,0 +1,3 @@ +Runtime directory

diff --git a/tests/_data/cli/generation/generated-file-writer/class-import-collision.stub b/tests/_data/cli/generation/generated-file-writer/class-import-collision.stub new file mode 100644 index 0000000..8fa3caf --- /dev/null +++ b/tests/_data/cli/generation/generated-file-writer/class-import-collision.stub @@ -0,0 +1,9 @@ +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): object => new \stdClass() ); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/closure-without-array-return.stub b/tests/_data/cli/generation/php-source-editor/closure-without-array-return.stub new file mode 100644 index 0000000..b5a838a --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/closure-without-array-return.stub @@ -0,0 +1,15 @@ +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static function ( C $c ): array { + return $c->get(Generated::class); + } ); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/closure-without-container-parameter.stub b/tests/_data/cli/generation/php-source-editor/closure-without-container-parameter.stub new file mode 100644 index 0000000..eeebc5a --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/closure-without-container-parameter.stub @@ -0,0 +1,15 @@ +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static function (): array { + return [ + ]; + } ); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/database-provider-without-registration-points.stub b/tests/_data/cli/generation/php-source-editor/database-provider-without-registration-points.stub new file mode 100644 index 0000000..1a082e5 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/database-provider-without-registration-points.stub @@ -0,0 +1,10 @@ +registerTables(); + $this->registerMigrations(); + } + + private function registerTables(): void + { + $this->container->singleton( Reports_Table::class ); + // foundation:database-tables + } + + private function registerMigrations(): void + { + $this->container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ + $c->get( Create_Reports_Table::class ), + ] ); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/function-import.stub b/tests/_data/cli/generation/php-source-editor/function-import.stub new file mode 100644 index 0000000..156a1f6 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/function-import.stub @@ -0,0 +1,7 @@ +container->singleton(Tables\Reports_Table::class); + + $this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn ($c): array => [ + $c->get(Migrations\Create_Reports_Table::class), + ]); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/imported-prefix-registrations.stub b/tests/_data/cli/generation/php-source-editor/imported-prefix-registrations.stub new file mode 100644 index 0000000..df89920 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/imported-prefix-registrations.stub @@ -0,0 +1,17 @@ +container->singleton(DB\Tables\Reports_Table::class); + + $this->container->mergeArrayVar(FoundationDatabase\DatabaseProvider::MIGRATIONS, static fn ($c): array => [ + $c->get(DB\Migrations\Create_Reports_Table::class), + ]); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/migration-class-reference-without-registration.stub b/tests/_data/cli/generation/php-source-editor/migration-class-reference-without-registration.stub new file mode 100644 index 0000000..e86ab65 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/migration-class-reference-without-registration.stub @@ -0,0 +1,17 @@ +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ + Create_Reports_Table::class, + ] ); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/multiple-migration-contributions.stub b/tests/_data/cli/generation/php-source-editor/multiple-migration-contributions.stub new file mode 100644 index 0000000..64e7a85 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/multiple-migration-contributions.stub @@ -0,0 +1,17 @@ +container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn ($c): array => [$c->get(Existing::class)]); + + $this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn ($c): array => [ + $c->get(Create_Reports_Table::class), + ]); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/namespace-relative-registrations.stub b/tests/_data/cli/generation/php-source-editor/namespace-relative-registrations.stub new file mode 100644 index 0000000..347bf7b --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/namespace-relative-registrations.stub @@ -0,0 +1,18 @@ +container->singleton(Tables\Reports_Table::class); + // foundation:database-tables + + $this->container->mergeArrayVar(DatabaseProvider::MIGRATIONS, static fn ($c): array => [ + $c->get(Migrations\Create_Reports_Table::class), + // foundation:database-migrations + ]); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/nested-migration-registration.stub b/tests/_data/cli/generation/php-source-editor/nested-migration-registration.stub new file mode 100644 index 0000000..a6805ed --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/nested-migration-registration.stub @@ -0,0 +1,18 @@ +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, static fn ( C $c ): array => [ + new Wrapper( $c->get( Create_Reports_Table::class ) ), + ] ); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/not-container-merge-array-var.stub b/tests/_data/cli/generation/php-source-editor/not-container-merge-array-var.stub new file mode 100644 index 0000000..ad3f235 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/not-container-merge-array-var.stub @@ -0,0 +1,12 @@ +mergeArrayVar( DatabaseProvider::MIGRATIONS, [ ] ); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/space-indented-registration-list.stub b/tests/_data/cli/generation/php-source-editor/space-indented-registration-list.stub new file mode 100644 index 0000000..2cc95e8 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/space-indented-registration-list.stub @@ -0,0 +1,14 @@ +container->mergeArrayVar( DatabaseProvider::MIGRATIONS, [ + $this->container->get(Existing::class), + ] ); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/strauss-prefixed-database-provider.stub b/tests/_data/cli/generation/php-source-editor/strauss-prefixed-database-provider.stub new file mode 100644 index 0000000..f0b7482 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/strauss-prefixed-database-provider.stub @@ -0,0 +1,11 @@ +container->mergeArrayVar( \Acme\Product\StellarWP\Foundation\Database\DatabaseProvider::MIGRATIONS, [ + ] ); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/wrong-constant-merge-array-var.stub b/tests/_data/cli/generation/php-source-editor/wrong-constant-merge-array-var.stub new file mode 100644 index 0000000..8870933 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/wrong-constant-merge-array-var.stub @@ -0,0 +1,12 @@ +container->mergeArrayVar( DatabaseProvider::TABLES, [ ] ); + } +} diff --git a/tests/_data/cli/generation/php-source-editor/wrong-first-argument-merge-array-var.stub b/tests/_data/cli/generation/php-source-editor/wrong-first-argument-merge-array-var.stub new file mode 100644 index 0000000..5399fc6 --- /dev/null +++ b/tests/_data/cli/generation/php-source-editor/wrong-first-argument-merge-array-var.stub @@ -0,0 +1,10 @@ +container->mergeArrayVar( 'migrations', [ ] ); + } +} diff --git a/tests/_output/coverage/.gitignore b/tests/_output/coverage/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/tests/_output/coverage/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/config.php b/tests/config.php index e5d30d6..33b8903 100644 --- a/tests/config.php +++ b/tests/config.php @@ -6,9 +6,10 @@ * @see \StellarWP\Foundation\Tests\TestCase::setUp() * @see \Adbar\Dot * @see phpunit.xml.dist + * @see .env.testing.slic */ return [ - 'log' => [ + 'log' => [ 'level' => $_ENV['TEST_LOG_LEVEL'] ?? 'debug', 'channel' => $_ENV['TEST_LOG_CHANNEL'] ?? 'null', 'channels' => [ @@ -25,4 +26,7 @@ ], ], ], + 'wpcli' => [ + 'command_prefix' => $_ENV['TEST_COMMAND_PREFIX'] ?? 'nxtest', + ], ]; diff --git a/tests/integration.suite.dist.yml b/tests/integration.suite.dist.yml new file mode 100644 index 0000000..8788dab --- /dev/null +++ b/tests/integration.suite.dist.yml @@ -0,0 +1,20 @@ +# Codeception Test Suite Configuration + +actor: IntegrationTester +path: integration +modules: + enabled: + - lucatume\WPBrowser\Module\WPLoader + - lucatume\WPBrowser\Module\WPQueries + config: + lucatume\WPBrowser\Module\WPLoader: + wpRootFolder: %WP_ROOT_FOLDER% + dbName: %WP_TEST_DB_NAME% + dbHost: %WP_TEST_DB_HOST% + dbUser: %WP_TEST_DB_USER% + dbPassword: %WP_TEST_DB_PASSWORD% + tablePrefix: %WP_TABLE_PREFIX% + domain: %WP_DOMAIN% + adminEmail: admin@stellarwp.com + title: 'Foundation Tests' + theme: twentytwentythree diff --git a/tests/integration/Database/DatabaseProviderTest.php b/tests/integration/Database/DatabaseProviderTest.php new file mode 100644 index 0000000..8a9a133 --- /dev/null +++ b/tests/integration/Database/DatabaseProviderTest.php @@ -0,0 +1,209 @@ +container->register(WPCliProvider::class); + $this->container->register(DatabaseProvider::class); + + $commands = $this->container->get(WPCliProvider::COMMANDS); + + $this->assertSame([], $this->container->get(DatabaseProvider::MIGRATIONS)); + $this->assertSame('nx-foundation-database-migrations', $this->container->get(DatabaseProvider::LOCK_NAME)); + $this->assertSame(300, $this->container->get(DatabaseProvider::LOCK_TTL)); + $this->assertContainsOnlyInstancesOf(Command::class, $commands); + $this->assertTrue($this->containsMigrateCommand((array) $commands)); + $this->assertInstanceOf(DatabaseLock::class, $this->container->get(DatabaseLock::class)); + $this->assertInstanceOf(Migrator::class, $this->container->get(Migrator::class)); + $this->assertInstanceOf(Migrate::class, $this->container->get(Migrate::class)); + } + + public function test_it_registers_configured_database_configuration(): void { + $container = $this->newContainer([ + 'foundation' => [ + 'prefix' => 'your-plugin', + ], + 'database' => [ + 'migrations_table' => 'custom_migrations', + 'locks_table' => 'custom_locks', + 'lock_name' => 'custom-migrations', + 'lock_ttl' => '120', + ], + 'wpcli' => [ + 'command_prefix' => 'custom', + ], + ]); + + $container->register(WPCliProvider::class); + $container->register(DatabaseProvider::class); + + $this->assertSame('custom_migrations', $container->get(DatabaseProvider::MIGRATIONS_TABLE)); + $this->assertSame('custom_locks', $container->get(DatabaseProvider::LOCKS_TABLE)); + $this->assertSame('custom_migrations', $container->get(MigrationTable::class)->name()); + $this->assertSame('custom_locks', $container->get(LockTable::class)->name()); + $this->assertSame('custom-migrations', $container->get(DatabaseProvider::LOCK_NAME)); + $this->assertSame(120, $container->get(DatabaseProvider::LOCK_TTL)); + $this->assertSame('custom', $container->get(CommandPrefix::class)->value); + } + + public function test_it_rejects_an_invalid_foundation_prefix_when_database_resources_are_overridden(): void { + $container = $this->newContainer([ + 'foundation' => [ + 'prefix' => 'Invalid Prefix', + ], + 'database' => [ + 'migrations_table' => 'custom_migrations', + 'locks_table' => 'custom_locks', + 'lock_name' => 'custom-migrations', + ], + ]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('lowercase kebab-case'); + + $container->register(DatabaseProvider::class); + } + + public function test_it_scopes_default_resources_with_the_foundation_prefix(): void { + $container = $this->newContainer([ + 'foundation' => [ + 'prefix' => 'your-plugin', + ], + ]); + + $container->register(WPCliProvider::class); + $container->register(DatabaseProvider::class); + + $this->assertSame($GLOBALS['wpdb']->prefix . 'your_plugin_foundation_migrations', $container->get(DatabaseProvider::MIGRATIONS_TABLE)); + $this->assertSame($GLOBALS['wpdb']->prefix . 'your_plugin_foundation_locks', $container->get(DatabaseProvider::LOCKS_TABLE)); + $this->assertSame('your-plugin-foundation-database-migrations', $container->get(DatabaseProvider::LOCK_NAME)); + $this->assertSame('your-plugin', $container->get(CommandPrefix::class)->value); + } + + public function test_it_applies_configured_lock_policy_to_the_migration_store(): void { + $configurations = [ + [['database' => ['lock_name' => ' ']], 'lock name cannot be empty'], + [['database' => ['lock_ttl' => 0]], 'TTL must be at least one second'], + ]; + + foreach ($configurations as [$config, $message]) { + $container = $this->newContainer($config); + $container->register(WPCliProvider::class); + $container->register(DatabaseProvider::class); + + try { + $container->get(Migrator::class); + $this->fail('Expected invalid migration lock configuration to be rejected.'); + } catch (ContainerException $exception) { + $this->assertInstanceOf(InvalidArgumentException::class, $exception->getPrevious()); + $this->assertStringContainsString($message, $exception->getMessage()); + } + } + } + + public function test_it_preserves_preconfigured_migrations(): void { + $migration = new TestMigration('2026_06_23_000001_create_example'); + $container = $this->newContainer(); + $container->mergeArrayVar(DatabaseProvider::MIGRATIONS, [$migration]); + + $container->register(WPCliProvider::class); + $container->register(DatabaseProvider::class); + + $this->assertSame([$migration], $container->get(DatabaseProvider::MIGRATIONS)); + $this->assertSame([$migration->id() => $migration], $container->get(Collection::class)->all()); + $this->assertSame([$migration], $container->get(Collection::class)->values()); + } + + public function test_it_collects_migrations_added_after_provider_registration(): void { + $migration = new TestMigration('2026_06_23_000001_create_example'); + $container = $this->newContainer(); + + $container->register(WPCliProvider::class); + $container->register(DatabaseProvider::class); + $container->mergeArrayVar(DatabaseProvider::MIGRATIONS, [$migration]); + + $this->assertSame([$migration->id() => $migration], $container->get(Collection::class)->all()); + $this->assertSame([$migration], $container->get(Collection::class)->values()); + } + + public function test_provider_built_migrator_executes_against_wordpress(): void { + $suffix = str_replace('.', '_', uniqid('', true)); + $migrationsTable = $GLOBALS['wpdb']->prefix . 'foundation_provider_migrations_' . $suffix; + $locksTable = $GLOBALS['wpdb']->prefix . 'foundation_provider_locks_' . $suffix; + $migration = new NoopMigration('2026_08_20_000001_provider_migration'); + $container = $this->newContainer([ + 'database' => [ + 'migrations_table' => $migrationsTable, + 'locks_table' => $locksTable, + ], + ]); + $container->mergeArrayVar(DatabaseProvider::MIGRATIONS, [$migration]); + $container->register(WPCliProvider::class); + $container->register(DatabaseProvider::class); + + $database = $container->get(Database::class); + + try { + $migrator = $container->get(Migrator::class); + $migrator->initialize(); + $result = $migrator->run(); + + $this->assertSame([$migration->id()], $result->ran); + $this->assertTrue($database->tableExists($migrationsTable)); + $this->assertTrue($database->tableExists($locksTable)); + $this->assertTrue($migrator->status()[0]->ran); + } finally { + $database->execute('DROP TABLE IF EXISTS %i', $migrationsTable); + $database->execute('DROP TABLE IF EXISTS %i', $locksTable); + } + } + + /** + * @param array $config + */ + private function newContainer(array $config = []): Container { + $container = new ContainerAdapter(new DI52Container()); + $container->bind(Container::class, $container); + $container->bind(ContainerInterface::class, $container); + $container->singleton(Dot::class, new Dot($config)); + + return $container; + } + + /** + * @param array $commands + */ + private function containsMigrateCommand(array $commands): bool { + foreach ($commands as $command) { + if ($command instanceof Migrate) { + return true; + } + } + + return false; + } +} diff --git a/tests/integration/WPCli/WPCliProviderTest.php b/tests/integration/WPCli/WPCliProviderTest.php new file mode 100644 index 0000000..8a5b6a2 --- /dev/null +++ b/tests/integration/WPCli/WPCliProviderTest.php @@ -0,0 +1,120 @@ +container->singleton(Dot::class, new Dot()); + $this->container->register(WPCliProvider::class); + + $commandPrefix = $this->container->get(CommandPrefix::class); + + $this->assertSame('nx', $commandPrefix->value); + $this->assertSame($commandPrefix, $this->container->get(CommandPrefix::class)); + } + + public function test_it_uses_the_foundation_prefix_by_default(): void { + $this->container->singleton(Dot::class, new Dot([ + 'foundation' => [ + 'prefix' => 'your-plugin', + ], + ])); + + $this->container->register(WPCliProvider::class); + + $this->assertSame('your-plugin', $this->container->get(CommandPrefix::class)->value); + } + + public function test_it_uses_the_package_specific_command_prefix(): void { + $this->container->singleton(Dot::class, new Dot([ + 'foundation' => [ + 'prefix' => 'your-plugin', + ], + 'wpcli' => [ + 'command_prefix' => 'your-plugin-tools', + ], + ])); + + $this->container->register(WPCliProvider::class); + + $this->assertSame('your-plugin-tools', $this->container->get(CommandPrefix::class)->value); + } + + public function test_it_rejects_an_invalid_foundation_prefix_when_the_command_prefix_is_overridden(): void { + $this->container->singleton(Dot::class, new Dot([ + 'foundation' => [ + 'prefix' => 'Invalid Prefix', + ], + 'wpcli' => [ + 'command_prefix' => 'custom', + ], + ])); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('lowercase kebab-case'); + + $this->container->register(WPCliProvider::class); + } + + public function test_it_registers_configured_commands_on_cli_init(): void { + $this->container->singleton(Dot::class, new Dot([ + 'wpcli' => [ + 'command_prefix' => 'your-plugin-tools', + ], + ])); + + RecordingCommand::$registered = false; + RecordingCommand::$registeredName = null; + $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ + $c->get(RecordingCommand::class), + ]); + + $this->container->register(WPCliProvider::class); + + do_action('cli_init'); + + $this->assertTrue(RecordingCommand::$registered); + $this->assertSame('your-plugin-tools recording', RecordingCommand::$registeredName); + } + + public function test_it_rejects_invalid_commands_before_registering_any_command(): void { + RecordingCommand::$registered = false; + RecordingCommand::$registeredName = null; + $this->container->mergeArrayVar(WPCliProvider::COMMANDS, static fn (C $c): array => [ + $c->get(RecordingCommand::class), + new stdClass(), + ]); + $this->container->register(WPCliProvider::class); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('must extend'); + + try { + do_action('cli_init'); + } finally { + $this->assertFalse(RecordingCommand::$registered); + $this->assertNull(RecordingCommand::$registeredName); + } + } + + public function test_it_rejects_a_non_iterable_command_list(): void { + $this->container->register(WPCliProvider::class); + $this->container->bind(WPCliProvider::COMMANDS, 'invalid'); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('commands must be iterable; received string'); + + do_action('cli_init'); + } +} diff --git a/tests/redis.suite.dist.yml b/tests/redis.suite.dist.yml new file mode 100644 index 0000000..fe00b31 --- /dev/null +++ b/tests/redis.suite.dist.yml @@ -0,0 +1,6 @@ +# Codeception Test Suite Configuration + +actor: RedisTester +path: redis +modules: + enabled: [] diff --git a/tests/redis/LockRedis/RedisLockIntegrationTest.php b/tests/redis/LockRedis/RedisLockIntegrationTest.php new file mode 100644 index 0000000..16b55da --- /dev/null +++ b/tests/redis/LockRedis/RedisLockIntegrationTest.php @@ -0,0 +1,162 @@ +prefix = sprintf('tests:lock:%s:', str_replace('.', '', uniqid('', true))); + + $this->phpRedis = new Redis(); + $this->phpRedis->connect($host, $port); + $this->phpRedis->select($database); + + $this->predis = new Client([ + 'scheme' => 'tcp', + 'host' => $host, + 'port' => $port, + 'database' => $database, + ]); + + $this->phpRedisLock = new RedisLock(new PhpRedisConnection($this->phpRedis), new SystemClock(), $this->prefix); + $this->predisLock = new RedisLock(new PredisConnection($this->predis), new SystemClock(), $this->prefix); + } + + protected function tearDown(): void { + $this->predis->disconnect(); + $this->phpRedis->close(); + + parent::tearDown(); + } + + public function test_phpredis_and_predis_coordinate_the_same_lock(): void { + $first = $this->phpRedisLock->acquire('queue:sync', 10); + + $this->assertInstanceOf(LockToken::class, $first); + $this->assertNull($this->predisLock->acquire('queue:sync', 10)); + $this->assertTrue($this->predisLock->isAcquired('queue:sync')); + $this->assertFalse($this->predisLock->release(new LockToken( + name: 'queue:sync', + owner: 'another-owner', + expiresAt: new DateTimeImmutable('+10 seconds') + ))); + $this->assertTrue($this->phpRedisLock->release($first)); + + $second = $this->predisLock->acquire('queue:sync', 10); + + $this->assertInstanceOf(LockToken::class, $second); + $this->assertFalse($this->phpRedisLock->release($first)); + $this->assertTrue($this->predisLock->release($second)); + } + + public function test_an_expired_owner_cannot_modify_its_replacement(): void { + $expired = $this->predisLock->acquire('queue:sync', 1); + + $this->assertInstanceOf(LockToken::class, $expired); + + sleep(2); + + $replacement = $this->phpRedisLock->acquire('queue:sync', 10); + + $this->assertInstanceOf(LockToken::class, $replacement); + $this->assertNull($this->predisLock->refresh($expired, 10)); + $this->assertFalse($this->predisLock->release($expired)); + $this->assertTrue($this->phpRedisLock->isAcquired('queue:sync')); + $this->assertTrue($this->phpRedisLock->release($replacement)); + } + + public function test_refresh_extends_the_authoritative_redis_lease(): void { + $token = $this->phpRedisLock->acquire('queue:sync', 1); + + $this->assertInstanceOf(LockToken::class, $token); + + $refreshed = $this->predisLock->refresh($token, 3); + + $this->assertInstanceOf(LockToken::class, $refreshed); + + sleep(2); + + $this->assertTrue($this->phpRedisLock->isAcquired('queue:sync')); + $this->assertTrue($this->predisLock->release($refreshed)); + } + + public function test_predis_errors_fail_closed(): void { + $this->expectException(LockUnavailableException::class); + + (new PredisConnection($this->predis))->evaluate('not valid lua', [], []); + } + + public function test_phpredis_errors_fail_closed(): void { + $this->expectException(LockUnavailableException::class); + + (new PhpRedisConnection($this->phpRedis))->evaluate('return false', [], []); + } + + public function test_phpredis_serialization_does_not_change_lock_ownership(): void { + $this->phpRedis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_PHP); + + $this->assertPhpRedisOwnerCanRefreshAndRelease(); + } + + public function test_phpredis_compression_does_not_change_lock_ownership(): void { + $this->phpRedis->setOption(Redis::OPT_COMPRESSION, Redis::COMPRESSION_LZF); + + $this->assertPhpRedisOwnerCanRefreshAndRelease(); + } + + public function test_retried_acquisition_recognizes_the_same_owner_without_extending_the_ttl(): void { + $recording = new RecordingConnection(); + $token = (new RedisLock($recording, new SystemClock(), $this->prefix))->acquire('queue:retry', 10); + + $this->assertInstanceOf(LockToken::class, $token); + + $call = $recording->evaluateCalls[0]; + $connection = new PhpRedisConnection($this->phpRedis); + + $this->assertSame(1, $connection->evaluate($call['script'], $call['keys'], $call['arguments'])); + + sleep(2); + + $this->assertSame(1, $connection->evaluate($call['script'], $call['keys'], $call['arguments'])); + $this->assertLessThan(10, $this->phpRedis->ttl($call['keys'][0])); + $this->assertTrue($this->phpRedisLock->release($token)); + } + + private function assertPhpRedisOwnerCanRefreshAndRelease(): void { + $token = $this->phpRedisLock->acquire('queue:sync', 10); + + $this->assertInstanceOf(LockToken::class, $token); + + $refreshed = $this->phpRedisLock->refresh($token, 20); + + $this->assertInstanceOf(LockToken::class, $refreshed); + $this->assertTrue($this->phpRedisLock->release($refreshed)); + } +} diff --git a/tests/wpcli.suite.dist.yml b/tests/wpcli.suite.dist.yml new file mode 100644 index 0000000..f5305da --- /dev/null +++ b/tests/wpcli.suite.dist.yml @@ -0,0 +1,16 @@ +# Codeception Test Suite Configuration + +actor: WPCLITester +path: wpcli +modules: + enabled: + - lucatume\WPBrowser\Module\WPCLI + config: + lucatume\WPBrowser\Module\WPCLI: + path: %WP_ROOT_FOLDER% + url: %WP_URL% + user: %WP_ADMIN_USERNAME% + require: + - /var/www/html/wp-content/plugins/foundation/tests/Support/Fixtures/Database/register-wpcli-migrate-command.php + throw: false + allow-root: true diff --git a/tests/wpcli/Database/Cli/DatabaseMigrateCest.php b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php new file mode 100644 index 0000000..773f9cb --- /dev/null +++ b/tests/wpcli/Database/Cli/DatabaseMigrateCest.php @@ -0,0 +1,108 @@ +dropTables($I); + } + + public function _after(WPCLITester $I): void { + $this->dropTables($I); + } + + public function test_it_runs_database_migrations_through_wp_cli(WPCLITester $I): void { + $I->cli(['foundation', 'migrate', '--initialize']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Foundation migration storage is initialized.'); + + $I->cli(['foundation', 'migrate', '--run']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Ran 1 migrations.'); + + $I->cli(['foundation', 'migrate']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('2026_06_23_000001_create_foundation_cli_example'); + $I->seeInShellOutput('ran'); + + $I->cli(['foundation', 'migrate', '--rollback']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Rolled back 1 migrations.'); + } + + public function test_it_refreshes_and_drops_database_tables_through_wp_cli(WPCLITester $I): void { + $I->cli(['foundation', 'migrate', '--initialize']); + $I->seeResultCodeIs(0); + + $I->cli(['foundation', 'migrate', '--run']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Ran 1 migrations.'); + + $I->cli(['foundation', 'migrate', '--refresh', '--yes']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('Rolled back 1 migrations and ran 1 migrations.'); + + $I->cli(['foundation', 'migrate', '--drop-store', '--yes']); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('The migration ledger was dropped. Application tables were not changed, and shared lock storage remains available.'); + + $I->cli([ + 'db', + 'query', + sprintf("SHOW TABLES LIKE '%sfoundation_cli_example'", $this->prefix($I)), + ]); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('foundation_cli_example'); + + $I->cli([ + 'db', + 'query', + sprintf("SHOW TABLES LIKE '%sfoundation_cli_locks'", $this->prefix($I)), + ]); + $I->seeResultCodeIs(0); + $I->seeInShellOutput('foundation_cli_locks'); + + $I->cli(['foundation', 'migrate']); + $I->seeResultCodeIs(0); + Assert::assertStringContainsString('Migration storage is not initialized.', $I->grabLastShellErrorOutput()); + } + + public function test_it_warns_when_showing_status_before_tables_exist(WPCLITester $I): void { + $I->cli(['foundation', 'migrate', '--run', '--initialize']); + $I->seeResultCodeIs(1); + Assert::assertStringContainsString('Only one migration operation can be used at a time.', $I->grabLastShellErrorOutput()); + + $I->cli(['foundation', 'migrate']); + $I->seeResultCodeIs(0); + Assert::assertStringContainsString('Migration storage is not initialized.', $I->grabLastShellErrorOutput()); + Assert::assertStringContainsString('wp foundation migrate --initialize', $I->grabLastShellErrorOutput()); + + $I->cli(['foundation', 'migrate', '--run']); + $I->seeResultCodeIs(1); + Assert::assertStringContainsString('wp foundation migrate --initialize', $I->grabLastShellErrorOutput()); + } + + private function dropTables(WPCLITester $I): void { + $prefix = $this->prefix($I); + + $I->cli([ + 'db', + 'query', + sprintf( + 'DROP TABLE IF EXISTS %sfoundation_cli_migrations, %sfoundation_cli_locks, %sfoundation_cli_example', + $prefix, + $prefix, + $prefix + ), + ]); + $I->seeResultCodeIs(0); + } + + private function prefix(WPCLITester $I): string { + $I->cli(['db', 'prefix']); + $I->seeResultCodeIs(0); + + return trim($I->grabLastShellOutput()); + } +} diff --git a/tests/wpunit.suite.dist.yml b/tests/wpunit.suite.dist.yml index 0b6007e..1792056 100644 --- a/tests/wpunit.suite.dist.yml +++ b/tests/wpunit.suite.dist.yml @@ -13,7 +13,7 @@ modules: dbHost: %WP_TEST_DB_HOST% dbUser: %WP_TEST_DB_USER% dbPassword: %WP_TEST_DB_PASSWORD% - tablePrefix: test_ + tablePrefix: %WP_TABLE_PREFIX% domain: %WP_DOMAIN% adminEmail: admin@stellarwp.com title: 'Foundation Tests' diff --git a/tests/wpunit/Database/DatabaseIntegrationTest.php b/tests/wpunit/Database/DatabaseIntegrationTest.php new file mode 100644 index 0000000..4fb1277 --- /dev/null +++ b/tests/wpunit/Database/DatabaseIntegrationTest.php @@ -0,0 +1,635 @@ + + */ + private array $tables = []; + + /** + * @throws RuntimeException When WordPress is not loaded. + */ + protected function setUp(): void { + parent::setUp(); + + if (! defined('ABSPATH')) { + throw new RuntimeException('WordPress must be loaded before running database integration tests.'); + } + + $this->database = new Database($GLOBALS['wpdb']); + $this->schema = new Schema($this->database, new Reconciler($this->database, new DbDelta())); + } + + protected function tearDown(): void { + foreach (array_reverse($this->tables) as $table) { + $this->database->execute(sprintf( + 'DROP TABLE IF EXISTS %s', + $this->database->quoteIdentifier($table) + )); + } + + parent::tearDown(); + } + + public function test_database_executes_and_reads_rows_through_wpdb(): void { + $table = $this->table('database'); + + $this->database->execute(sprintf( + 'CREATE TABLE %s ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + name varchar(191) NOT NULL, + PRIMARY KEY (id) + ) %s', + $this->database->quoteIdentifier($table), + $this->database->charsetCollate() + )); + + $this->database->execute( + 'INSERT INTO %i (name) VALUES (%s), (%s)', + $table, + 'first', + 'second' + ); + + $this->assertSame($GLOBALS['wpdb']->prefix . 'example', $this->database->tableName('example')); + $this->assertSame(['name' => 'first'], $this->database->row(sprintf( + 'SELECT name FROM %s WHERE id = 1', + $this->database->quoteIdentifier($table) + ))); + $this->assertSame([ + ['name' => 'first'], + ['name' => 'second'], + ], $this->database->rows(sprintf( + 'SELECT name FROM %s ORDER BY id ASC', + $this->database->quoteIdentifier($table) + ))); + $this->assertSame([ + ['name' => 'first'], + ], $this->database->table($table)->select('name')->where('id', '=', 1)->get()); + } + + public function test_database_rejects_table_names_beyond_the_mysql_identifier_limit(): void { + $maximum = str_repeat('a', 64 - strlen($GLOBALS['wpdb']->prefix)); + + $this->assertSame($GLOBALS['wpdb']->prefix . $maximum, $this->database->tableName($maximum)); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('64-character identifier limit'); + + $this->database->tableName(str_repeat('a', 65 - strlen($GLOBALS['wpdb']->prefix))); + } + + public function test_schema_rejects_table_objects_beyond_the_mysql_identifier_limit(): void { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('64-character identifier limit'); + + $this->schema->createOrUpdate(new TestTable('too_long', str_repeat('a', 65))); + } + + public function test_database_crud_helpers_and_schema_inspection_use_wordpress(): void { + $table = $this->table('crud'); + + $this->database->execute(sprintf( + 'CREATE TABLE %s ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + name varchar(191) NOT NULL, + status varchar(20) NOT NULL, + PRIMARY KEY (id), + KEY status (status) + ) %s', + $this->database->quoteIdentifier($table), + $this->database->charsetCollate() + )); + + $this->assertTrue($this->database->tableExists($table)); + $this->assertTrue($this->database->columnExists($table, 'status')); + $this->assertTrue($this->database->indexExists($table, 'status')); + $this->assertFalse($this->database->columnExists($table, 'missing')); + $this->assertFalse($this->database->indexExists($table, 'missing')); + + $id = $this->database->insertGetId($table, [ + 'name' => 'draft report', + 'status' => 'draft', + ]); + + $this->assertGreaterThan(0, $id); + $this->assertSame('draft', $this->database->value('SELECT status FROM %i WHERE id = %d', $table, $id)); + $this->assertSame(1, $this->database->update($table, ['status' => 'published'], ['id' => $id])); + $this->assertSame('published', $this->database->value('SELECT status FROM %i WHERE id = %d', $table, $id)); + $this->assertSame(1, $this->database->delete($table, ['id' => $id])); + $this->assertSame('0', (string) $this->database->value('SELECT COUNT(*) FROM %i', $table)); + } + + public function test_database_insert_returns_affected_rows_for_string_identifiers(): void { + $table = $this->table('string_ids'); + + $this->database->execute(sprintf( + 'CREATE TABLE %s ( + id varchar(26) NOT NULL, + name varchar(191) NOT NULL, + PRIMARY KEY (id) + ) %s', + $this->database->quoteIdentifier($table), + $this->database->charsetCollate() + )); + + $this->assertSame(1, $this->database->insert($table, [ + 'id' => '01J2Z3Y4X5W6V7T8S9R0Q1P2N3', + 'name' => 'report', + ])); + } + + public function test_database_returns_empty_results_without_query_errors(): void { + $table = $this->table('missing_value'); + + $this->database->execute(sprintf( + 'CREATE TABLE %s ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + name varchar(191) NOT NULL, + PRIMARY KEY (id) + ) %s', + $this->database->quoteIdentifier($table), + $this->database->charsetCollate() + )); + + $this->assertNull($this->database->value('SELECT name FROM %i WHERE id = %d', $table, 999)); + $this->assertSame([], $this->database->rows('SELECT name FROM %i WHERE id = %d', $table, 999)); + } + + public function test_database_rejects_blank_sql(): void { + $this->expectException(QueryException::class); + $this->expectExceptionMessage('SQL statement cannot be empty.'); + + $this->database->prepare(' '); + } + + public function test_database_wraps_wordpress_query_failures(): void { + $previous = $GLOBALS['wpdb']->suppress_errors(true); + + try { + $exception = $this->assertQueryFails(fn (): mixed => $this->database->rows('SELECT * FROM %i', 'missing_foundation_table')); + + $this->assertSame('SELECT * FROM %i', $exception->sql()); + $this->assertSame(['missing_foundation_table'], $exception->bindings()); + $this->assertNotNull($exception->databaseError()); + + $this->assertQueryFails(fn (): mixed => $this->database->row('SELECT * FROM %i', 'missing_foundation_table')); + $this->assertQueryFails(fn (): mixed => $this->database->execute('SELECT * FROM %i', 'missing_foundation_table')); + $this->assertQueryFails(fn (): mixed => $this->database->insert('missing_foundation_table', ['name' => 'test'])); + $this->assertQueryFails(fn (): mixed => $this->database->update('missing_foundation_table', ['name' => 'updated'], ['id' => 1])); + $this->assertQueryFails(fn (): mixed => $this->database->delete('missing_foundation_table', ['id' => 1])); + } finally { + $GLOBALS['wpdb']->suppress_errors($previous); + } + } + + public function test_db_delta_reports_query_failures(): void { + $table = $this->table('invalid_schema'); + $previous = $GLOBALS['wpdb']->suppress_errors(true); + + try { + $this->assertQueryFails(function () use ($table): void { + (new DbDelta())->execute(sprintf( + 'CREATE TABLE %s ( + id definitely_invalid NOT NULL, + PRIMARY KEY (id) + ) %s;', + $this->database->quoteIdentifier($table), + $this->database->charsetCollate() + )); + }); + } finally { + $GLOBALS['wpdb']->suppress_errors($previous); + } + } + + public function test_schema_creates_inspects_and_changes_tables_through_wordpress(): void { + $table = $this->table('schema'); + $schema = $this->schema; + + $schema->createOrUpdate(new TestTable('schema_table', $table)); + $schema->execute(sprintf( + 'ALTER TABLE %s ADD KEY %s (%s)', + $this->database->quoteIdentifier($table), + $this->database->quoteIdentifier('name'), + $this->database->quoteIdentifier('id') + )); + $this->assertTrue($schema->hasTable($table)); + $this->assertTrue($schema->hasIndex($table, 'name')); + + $schema->dropIndex($table, 'name'); + + $this->assertFalse($schema->hasIndex($table, 'name')); + + $schema->execute(sprintf( + 'DROP TABLE IF EXISTS %s', + $this->database->quoteIdentifier($table) + )); + + $this->assertFalse($schema->hasTable($table)); + } + + public function test_schema_creates_queue_style_table_definitions_through_wordpress(): void { + $table = $this->table('queue_schema'); + $schema = $this->schema; + $queue = new class($this->database, $table) implements Table { + public function __construct( + private DatabaseContract $database, + private string $table + ) { + } + + public function id(): string { + return 'queue_schema_table'; + } + + public function name(): string { + return $this->database->tableName($this->table); + } + + public function definition(): TableDefinition { + return TableDefinition::for($this) + ->bigIncrements('id') + ->string('queue', 255) + ->string('task_handler', 255) + ->longText('args') + ->integer('priority', 3)->nullable() + ->dateTime('run_after')->default('0000-00-00 00:00:00') + ->integer('taken')->default(0) + ->integer('done')->nullable()->default(0) + ->tinyInteger('tries')->unsigned()->default(0) + ->tinyInteger('failed', 1)->unsigned()->default(false) + ->index('done', 'done') + ->index('taken_failed', 'taken', 'failed') + ->index('taken_failed_done', 'taken', 'failed', 'done'); + } + }; + + $schema->createOrUpdate($queue); + + $this->assertTrue($schema->hasTable($queue)); + $this->assertTrue($this->database->columnExists($queue, 'args')); + $this->assertTrue($this->database->columnExists($queue, 'priority')); + $this->assertTrue($this->database->columnExists($queue, 'failed')); + $this->assertTrue($schema->hasIndex($queue, 'taken_failed')); + $this->assertTrue($schema->hasIndex($queue, 'taken_failed_done')); + } + + public function test_datetime_zero_precision_is_canonical_and_idempotent(): void { + $table = new DateTimePrecisionTable($this->table('datetime_zero')); + + $this->schema->createOrUpdate($table); + $this->schema->createOrUpdate($table); + + $column = $this->database->row('SHOW COLUMNS FROM %i WHERE Field = %s', $table->name(), 'occurred_at'); + + $this->assertSame('datetime', strtolower((string) ($column['Type'] ?? ''))); + } + + public function test_schema_preserves_quote_and_backslash_string_defaults(): void { + $tableName = $this->table('string_default'); + $default = "customer's \\ path"; + $table = static function (string $columnDefault) use ($tableName): Table { + return new class($tableName, $columnDefault) implements Table { + public function __construct( + private string $table, + private string $default + ) { + } + + public function id(): string { + return 'string_default_table'; + } + + public function name(): string { + return $this->table; + } + + public function definition(): TableDefinition { + return TableDefinition::for($this) + ->bigIncrements('id') + ->string('label', 100)->default($this->default); + } + }; + }; + + $this->schema->createOrUpdate($table('initial')); + $this->schema->createOrUpdate($table($default)); + $this->schema->createOrUpdate($table($default)); + $this->database->execute('INSERT INTO %i () VALUES ()', $tableName); + + $this->assertSame($default, $this->database->value('SELECT label FROM %i LIMIT 1', $tableName)); + } + + public function test_schema_rejects_unapplied_numeric_defaults_and_nullability(): void { + $table = $this->table('column_properties'); + + $this->schema->createOrUpdate(new SchemaReconciliationTable($table, 1, false)); + + try { + $this->schema->createOrUpdate(new SchemaReconciliationTable($table, 5, true)); + $this->fail('Expected unapplied column properties to fail schema reconciliation.'); + } catch (DatabaseException $exception) { + $this->assertStringContainsString('column attempts expected DEFAULT 5, found DEFAULT 1', $exception->getMessage()); + $this->assertStringContainsString('column completed_at expected NULL, found NOT NULL', $exception->getMessage()); + } + + $this->database->execute( + 'ALTER TABLE %i MODIFY COLUMN attempts int(10) NOT NULL DEFAULT 5, MODIFY COLUMN completed_at datetime NULL', + $table + ); + $this->schema->createOrUpdate(new SchemaReconciliationTable($table, 5, true)); + $this->database->execute('INSERT INTO %i (completed_at) VALUES (NULL)', $table); + + $row = $this->database->row('SELECT attempts, completed_at FROM %i LIMIT 1', $table); + + $this->assertSame('5', $row['attempts'] ?? null); + $this->assertNull($row['completed_at'] ?? null); + } + + public function test_schema_rejects_an_unapplied_auto_increment_attribute(): void { + $table = $this->table('column_extra'); + + $this->database->execute(sprintf( + 'CREATE TABLE %s (id bigint(20) unsigned NOT NULL, PRIMARY KEY (id)) %s', + $this->database->quoteIdentifier($table), + $this->database->charsetCollate() + )); + + try { + $this->schema->createOrUpdate(new TestTable('column_extra', $table)); + $this->fail('Expected an unapplied AUTO_INCREMENT attribute to fail schema reconciliation.'); + } catch (DatabaseException $exception) { + $this->assertStringContainsString('column id expected extra auto_increment, found none', $exception->getMessage()); + } + + $this->database->execute('ALTER TABLE %i MODIFY COLUMN id bigint(20) unsigned NOT NULL AUTO_INCREMENT', $table); + $this->schema->createOrUpdate(new TestTable('column_extra', $table)); + } + + public function test_migration_repository_persists_records_in_wordpress(): void { + $table = $this->table('migrations'); + $schema = $this->schema; + $migrationTable = new MigrationTable($table); + $repository = new Repository($this->database, $table); + + $this->assertFalse($schema->hasTable($migrationTable)); + + $schema->createOrUpdate($migrationTable); + + $this->assertTrue($schema->hasTable($migrationTable)); + $this->assertSame($table, $migrationTable->name()); + $this->assertSame(1, $repository->nextBatch()); + + $record = $repository->recordRun('2026_06_23_000001_create_example_table', 1); + + $this->assertGreaterThan(0, $record->id); + $this->assertTrue($repository->hasRun('2026_06_23_000001_create_example_table')); + $this->assertSame(2, $repository->nextBatch()); + $this->assertSame(1, $repository->latestBatch()); + $this->assertArrayHasKey('2026_06_23_000001_create_example_table', $repository->all()); + $this->assertCount(1, $repository->recordsForBatch(1)); + $this->assertTrue($repository->deleteRun('2026_06_23_000001_create_example_table')); + $this->assertFalse($repository->hasRun('2026_06_23_000001_create_example_table')); + + $repository->recordRun('CreateReports', 2); + $repository->recordRun('createreports', 2); + + $this->assertTrue($repository->hasRun('CreateReports')); + $this->assertTrue($repository->hasRun('createreports')); + $this->assertCount(2, $repository->recordsForBatch(2)); + + $schema->drop($migrationTable); + + $this->assertFalse($schema->hasTable($migrationTable)); + } + + public function test_database_lock_coordinates_ownership_in_wordpress(): void { + $table = $this->table('locks'); + $wpSchema = $this->schema; + $lockTable = new LockTable($table); + $lock = new DatabaseLock($this->database, $table); + + $this->assertFalse($wpSchema->hasTable($lockTable)); + + $wpSchema->createOrUpdate($lockTable); + + $this->assertTrue($wpSchema->hasTable($lockTable)); + $this->assertSame($table, $lockTable->name()); + + $token = $lock->acquire('foundation:database:test', 60); + + $this->assertNotNull($token); + $this->assertNull($lock->acquire('foundation:database:test', 60)); + $this->assertTrue($lock->isAcquired('foundation:database:test')); + $this->assertNotNull($lock->refresh($token, 120)); + $this->assertTrue($lock->release($token)); + $this->assertFalse($lock->isAcquired('foundation:database:test')); + + $wpSchema->drop($lockTable); + + $this->assertFalse($wpSchema->hasTable($lockTable)); + } + + public function test_database_lock_replaces_expired_ownership_without_allowing_the_previous_owner_to_release_it(): void { + $table = $this->table('expired_locks'); + $wpSchema = $this->schema; + $lockTable = new LockTable($table); + $lock = new DatabaseLock($this->database, $table); + + $wpSchema->createOrUpdate($lockTable); + + $first = $lock->acquire('foundation:database:takeover', 60); + + $this->assertNotNull($first); + + $this->database->execute( + 'UPDATE %i SET expires_at = TIMESTAMPADD(SECOND, -1, UTC_TIMESTAMP(6)) WHERE name = %s', + $table, + 'foundation:database:takeover' + ); + + $second = $lock->acquire('foundation:database:takeover', 60); + + $this->assertNotNull($second); + $this->assertNotSame($first->owner, $second->owner); + $this->assertFalse($lock->release($first)); + $this->assertTrue($lock->isAcquired('foundation:database:takeover')); + $this->assertTrue($lock->release($second)); + + $wpSchema->drop($lockTable); + } + + public function test_database_lock_compares_names_and_owners_by_exact_bytes(): void { + $table = $this->table('exact_locks'); + $wpSchema = $this->schema; + $lockTable = new LockTable($table); + $lock = new DatabaseLock($this->database, $table); + + $wpSchema->createOrUpdate($lockTable); + + $upper = $lock->acquire('Catalog:1', 60); + $lower = $lock->acquire('catalog:1', 60); + + $this->assertNotNull($upper); + $this->assertNotNull($lower); + + $this->database->execute( + 'UPDATE %i SET owner = %s WHERE name = %s', + $table, + 'owner', + $lower->name + ); + + $this->assertFalse($lock->release(new LockToken($lower->name, 'OWNER', $lower->expiresAt))); + $this->assertTrue($lock->release(new LockToken($lower->name, 'owner', $lower->expiresAt))); + $this->assertTrue($lock->release($upper)); + + $wpSchema->drop($lockTable); + } + + public function test_lock_table_reconciles_an_existing_previous_definition(): void { + $table = $this->table('previous_lock_schema'); + $wpSchema = $this->schema; + $lockTable = new LockTable($table); + + $this->database->execute(sprintf( + 'CREATE TABLE %s ( + name varchar(191) NOT NULL, + owner varchar(64) NOT NULL, + expires_at datetime NOT NULL, + created_at datetime NOT NULL, + updated_at datetime NOT NULL, + PRIMARY KEY (name), + KEY expires_at (expires_at) + ) %s', + $this->database->quoteIdentifier($table), + $this->database->charsetCollate() + )); + + $wpSchema->createOrUpdate($lockTable); + + $name = $this->database->row('SHOW COLUMNS FROM %i WHERE Field = %s', $table, 'name'); + $owner = $this->database->row('SHOW COLUMNS FROM %i WHERE Field = %s', $table, 'owner'); + $expiration = $this->database->row('SHOW COLUMNS FROM %i WHERE Field = %s', $table, 'expires_at'); + + $this->assertSame('varbinary(191)', strtolower((string) ($name['Type'] ?? ''))); + $this->assertSame('varbinary(64)', strtolower((string) ($owner['Type'] ?? ''))); + $this->assertSame('datetime(6)', strtolower((string) ($expiration['Type'] ?? ''))); + + $wpSchema->drop($lockTable); + } + + public function test_migration_table_reconciles_case_insensitive_identifiers(): void { + $table = $this->table('previous_migration_schema'); + $wpSchema = $this->schema; + $migrationTable = new MigrationTable($table); + + $this->database->execute(sprintf( + 'CREATE TABLE %s ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + migration varchar(191) NOT NULL, + batch int(10) unsigned NOT NULL, + ran_at datetime NOT NULL, + PRIMARY KEY (id), + UNIQUE KEY migration (migration), + KEY batch (batch) + ) %s', + $this->database->quoteIdentifier($table), + $this->database->charsetCollate() + )); + + $wpSchema->createOrUpdate($migrationTable); + + $migration = $this->database->row('SHOW COLUMNS FROM %i WHERE Field = %s', $table, 'migration'); + + $this->assertSame('varbinary(191)', strtolower((string) ($migration['Type'] ?? ''))); + + $wpSchema->drop($migrationTable); + } + + public function test_provider_registers_wordpress_prefixed_database_services(): void { + $container = $this->newContainer(); + + $container->register(DatabaseProvider::class); + + $this->assertSame($GLOBALS['wpdb']->prefix . 'nx_foundation_migrations', $container->get(DatabaseProvider::MIGRATIONS_TABLE)); + $this->assertSame($GLOBALS['wpdb']->prefix . 'nx_foundation_locks', $container->get(DatabaseProvider::LOCKS_TABLE)); + $this->assertInstanceOf(Database::class, $container->get(Database::class)); + $this->assertInstanceOf(Database::class, $container->get(DatabaseContract::class)); + $this->assertInstanceOf(Schema::class, $container->get(Schema::class)); + $this->assertInstanceOf(MigrationTable::class, $container->get(MigrationTable::class)); + $this->assertInstanceOf(LockTable::class, $container->get(LockTable::class)); + $this->assertInstanceOf(Repository::class, $container->get(MigrationRecordRepositoryContract::class)); + $this->assertInstanceOf(Migrator::class, $container->get(Migrator::class)); + $this->assertFalse($container->has(Lock::class)); + } + + private function table(string $suffix): string { + $table = $GLOBALS['wpdb']->prefix . 'foundation_' . $suffix . '_' . str_replace('.', '_', uniqid('', true)); + + $this->tables[] = $table; + + return $table; + } + + /** + * @param callable(): mixed $callback + */ + private function assertQueryFails(callable $callback): QueryException { + try { + $callback(); + } catch (QueryException $exception) { + $this->assertNotSame('', $exception->getMessage()); + + return $exception; + } + + $this->fail('Expected the database operation to throw a query exception.'); + } + + private function newContainer(): Container { + $container = new ContainerAdapter(new DI52Container()); + $container->bind(Container::class, $container); + $container->bind(ContainerInterface::class, $container); + $container->singleton(Dot::class, new Dot()); + + return $container; + } +} diff --git a/tests/wpunit/Database/Schema/DbDeltaTest.php b/tests/wpunit/Database/Schema/DbDeltaTest.php new file mode 100644 index 0000000..50f1bc7 --- /dev/null +++ b/tests/wpunit/Database/Schema/DbDeltaTest.php @@ -0,0 +1,103 @@ +originalLastError = $GLOBALS['wpdb']->last_error; + $GLOBALS['wpdb']->last_error = ''; + } + + protected function tearDown(): void { + $GLOBALS['wpdb']->last_error = $this->originalLastError; + + parent::tearDown(); + } + + public function test_it_executes_and_verifies_the_schema_definition(): void { + $dbDelta = PHPMockery::mock('StellarWP\Foundation\Database\Schema', 'dbDelta'); + $dbDelta->with(self::SQL, true)->once()->andReturn([]); + $dbDelta->with(self::SQL, false)->once()->andReturn([]); + + (new DbDelta())->execute(self::SQL); + + $this->addToAssertionCount(1); + } + + public function test_it_fails_when_schema_changes_remain_pending(): void { + $dbDelta = PHPMockery::mock('StellarWP\Foundation\Database\Schema', 'dbDelta'); + $dbDelta->with(self::SQL, true)->once()->andReturn([]); + $dbDelta->with(self::SQL, false)->once()->andReturn([ + 'wp_example.name' => 'Added column wp_example.name', + ]); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Database schema reconciliation did not complete: Added column wp_example.name'); + + (new DbDelta())->execute(self::SQL); + } + + public function test_it_ignores_wordpress_62_created_table_dry_run_false_positives(): void { + $table = $GLOBALS['wpdb']->prefix . 'foundation_dbdelta_existing'; + $sql = sprintf('CREATE TABLE `%s` (id bigint)', $table); + + $GLOBALS['wpdb']->query(sprintf('CREATE TABLE `%s` (id bigint)', $table)); + + $dbDelta = PHPMockery::mock('StellarWP\Foundation\Database\Schema', 'dbDelta'); + $dbDelta->with($sql, true)->once()->andReturn([]); + $dbDelta->with($sql, false)->once()->andReturn([ + '`' . $table . '`' => 'Created table `' . $table . '`', + ]); + + try { + (new DbDelta())->execute($sql); + $this->addToAssertionCount(1); + } finally { + $GLOBALS['wpdb']->query(sprintf('DROP TABLE IF EXISTS `%s`', $table)); + } + } + + public function test_it_translates_wordpress_database_errors(): void { + $dbDelta = PHPMockery::mock('StellarWP\Foundation\Database\Schema', 'dbDelta'); + $dbDelta->with(self::SQL, true)->once()->andReturnUsing(static function (): array { + $GLOBALS['wpdb']->last_error = 'Could not alter the table.'; + + return []; + }); + + $this->expectException(QueryException::class); + $this->expectExceptionMessage('Could not alter the table.'); + + (new DbDelta())->execute(self::SQL); + } + + public function test_it_fails_when_the_global_wordpress_database_is_unavailable(): void { + $wpdb = $GLOBALS['wpdb']; + unset($GLOBALS['wpdb']); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('The global wpdb instance is not available.'); + + try { + (new DbDelta())->execute(self::SQL); + } finally { + $GLOBALS['wpdb'] = $wpdb; + } + } +} diff --git a/tests/wpunit/Shutdown/ShutdownProviderTest.php b/tests/wpunit/Shutdown/ShutdownProviderTest.php new file mode 100644 index 0000000..e15bb74 --- /dev/null +++ b/tests/wpunit/Shutdown/ShutdownProviderTest.php @@ -0,0 +1,104 @@ +container->has(ShutdownRunnerContract::class)) { + remove_action( + 'shutdown', + $this->container->callback(ShutdownRunnerContract::class, 'terminate'), + PHP_INT_MAX + ); + } + + parent::tearDown(); + } + + public function test_it_registers_a_singleton_runner_with_contributed_tasks(): void { + $calls = []; + + $this->container->register(ShutdownProvider::class); + $this->container->mergeArrayVar(ShutdownProvider::TASKS, [ + new ShutdownTask(new CallbackTerminable(static function () use (&$calls): void { + $calls[] = 'terminated'; + })), + ]); + + $runner = $this->container->get(ShutdownRunnerContract::class); + + $this->assertInstanceOf(ResponseFinishingRunner::class, $runner); + $this->assertSame($runner, $this->container->get(ShutdownRunnerContract::class)); + + $runner->terminate(); + + $this->assertSame(['terminated'], $calls); + } + + public function test_duplicate_provider_registration_does_not_replace_the_runner(): void { + $this->container->register(ShutdownProvider::class); + $runner = $this->container->get(ShutdownRunnerContract::class); + + $this->container->register(ShutdownProvider::class); + + $this->assertSame($runner, $this->container->get(ShutdownRunnerContract::class)); + } + + public function test_it_injects_a_registered_psr_logger(): void { + $handler = new TestHandler(); + + $this->container->singleton(LoggerInterface::class, new Logger('shutdown', [$handler])); + $this->container->register(ShutdownProvider::class); + + $this->container->get(ShutdownRunnerContract::class)->terminate(); + + $this->assertTrue($handler->hasDebugThatMatches('/Running shutdown tasks\./')); + } + + public function test_it_runs_contributed_tasks_on_wordpress_shutdown(): void { + $calls = []; + + $this->container->register(ShutdownProvider::class); + $callback = $this->container->callback(ShutdownRunnerContract::class, 'terminate'); + + $this->container->mergeArrayVar(ShutdownProvider::TASKS, [ + new ShutdownTask(new CallbackTerminable(static function () use (&$calls): void { + $calls[] = 'terminated'; + })), + ]); + + $this->assertSame(PHP_INT_MAX, has_action('shutdown', $callback)); + + $callback(); + + $this->assertSame(['terminated'], $calls); + } + + public function test_it_does_not_register_the_shutdown_hook_while_wordpress_is_installing(): void { + $wasInstalling = wp_installing(true); + + try { + $this->container->register(ShutdownProvider::class); + $callback = $this->container->callback(ShutdownRunnerContract::class, 'terminate'); + + $this->assertFalse(has_action('shutdown', $callback)); + $this->assertInstanceOf( + ResponseFinishingRunner::class, + $this->container->get(ShutdownRunnerContract::class) + ); + } finally { + wp_installing($wasInstalling); + } + } +}