diff --git a/.gitattributes b/.gitattributes index d73ea7e..3b1f4bc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,6 +9,7 @@ /docs export-ignore /tests export-ignore /examples export-ignore +/tools export-ignore /.gitattributes export-ignore /.gitignore export-ignore /.gitmessage export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a55cb5..2337dd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,6 +119,29 @@ jobs: - run: node --check resources/js/client.js - run: node --test tests/js/*.test.mjs + api-reference: + # ADR-0028: docs/api is generated from src/ and committed. It must match the source, and real + # front-matter tooling (gray-matter) must be able to read and follow it. + name: API reference + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + tools: composer:v2 + coverage: none + - run: composer install --no-interaction --no-progress --prefer-dist + - name: Reference matches the source + run: composer docs:check + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + # Only the front-matter parser is needed here, not Playwright or wp-env's lifecycle scripts. + - run: npm ci --ignore-scripts + - run: node --test tests/docs/api-reference.test.mjs + integration: # Adapters against a real WordPress + MySQL (wp-env tests container), PHPUnit 10 with a # wp-load.php bootstrap (ADR-0010 amendment). diff --git a/bin/generate-api-docs.php b/bin/generate-api-docs.php new file mode 100644 index 0000000..a619ab7 --- /dev/null +++ b/bin/generate-api-docs.php @@ -0,0 +1,44 @@ +build(); + + if (in_array('--check', $argv, true)) { + $problems = $generator->drift($files); + foreach ($problems as $problem) { + fwrite(STDERR, $problem . PHP_EOL); + } + if ($problems !== []) { + fwrite(STDERR, "The API reference is out of date. Run: composer docs\n"); + exit(1); + } + fwrite(STDOUT, sprintf("API reference is current (%d files).\n", count($files))); + exit(0); + } + + $generator->write($files); + fwrite(STDOUT, sprintf("Wrote %d files.\n", count($files))); + exit(0); +} catch (Throwable $error) { + fwrite(STDERR, 'Could not generate the API reference: ' . $error->getMessage() . PHP_EOL); + exit(2); +} diff --git a/composer.json b/composer.json index 45cad01..b6e3045 100644 --- a/composer.json +++ b/composer.json @@ -55,6 +55,7 @@ "autoload-dev": { "psr-4": { "Codad5\\WPToolkit\\Tests\\": "tests/", + "Codad5\\WPToolkit\\Tools\\ApiDocs\\": "tools/apidocs/src/", "WptkTodo\\": "examples/todo/src/" } }, @@ -71,7 +72,7 @@ "sort-packages": true }, "scripts": { - "lint": "@php bin/lint.php src tests bin bootstrap examples", + "lint": "@php bin/lint.php src tests bin bootstrap examples packages tools", "cs": "phpcs", "analyse": "phpstan analyse --memory-limit=1G", "test": "phpunit", @@ -81,8 +82,11 @@ "@cs", "@analyse", "@test", - "@i18n" + "@i18n", + "@docs:check" ], + "docs": "@php bin/generate-api-docs.php", + "docs:check": "@php bin/generate-api-docs.php --check", "i18n": "wp i18n make-pot . languages/wptoolkit.pot --slug=wptoolkit --domain=wptoolkit --package-name=WPToolkit --include=src,bootstrap", "test:integration": "npx wp-env run tests-cli --env-cwd=wp-content/plugins/wptoolkit vendor/bin/phpunit -c phpunit.integration.xml.dist" }, diff --git a/docs/README.md b/docs/README.md index 409f047..1ad6a46 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,6 +26,10 @@ order it gets built in, **reference/** holds facts that change. Agent rules live [Plan, timeline, dependency graph →](phases/README.md) · live progress in [STATUS.md](../STATUS.md) +## api/ + +[The API reference](api/index.md): one generated page per public class, in reading order, with machine-readable front matter ([ADR-0028](adr/0028-the-api-reference-is-generated-from-the-source.md)). `composer docs` regenerates it. + ## reference/ [Open decisions](reference/open-decisions.md) · [Tech stack](reference/tech-stack.md) · [Work log](reference/work-log.md) diff --git a/docs/adr/0028-the-api-reference-is-generated-from-the-source.md b/docs/adr/0028-the-api-reference-is-generated-from-the-source.md new file mode 100644 index 0000000..9299380 --- /dev/null +++ b/docs/adr/0028-the-api-reference-is-generated-from-the-source.md @@ -0,0 +1,75 @@ +# ADR-0028 — The API reference is generated from the source, with machine-readable front matter + +- **Status:** Accepted +- **Date:** 2026-10-18 +- **Deciders:** codad5 +- **Affects:** `docs/api/` (generated), `tools/apidocs/`, `bin/generate-api-docs.php`, `composer verify`, CI + +## Context + +The guides explain how to do things; nothing lists what exists. The plan deferred "API reference from +docblocks" to 1.1, and 0.x's hand-maintained `API.md` (5,075 lines) is the cautionary tale: it went stale +and was retired. The library has about 150 classes whose docblocks already say *why* (a repo rule), and its +readers now include AI agents and documentation tools that want to walk the reference in order and read +facts about each page without parsing prose. + +## Decision + +1. **The reference is generated, never hand-written.** `composer docs` reads `src/` with Reflection + (signatures, modifiers, defaults — always in step with the code) and the docblocks (the prose), and + writes `docs/api/`. The files are committed, so GitHub and agents can read them without running + anything. `composer docs:check` (part of `composer verify` and CI) fails when the files differ from the + source, or when a file is stale — the same pattern as the translation template. +2. **What is documented:** public classes, interfaces, enums and traits of the namespaces listed in + `tools/apidocs/config.json`; their public constants, enum cases, properties and methods. Anything tagged + `@internal` (a class, a method, a constant) is left out: hook callbacks and wiring are not API. + Namespaces are added to the config as their docblocks are reviewed. +3. **One page per class**, at `docs/api/{Layer}/…/{Class}.md`, plus `{Layer}/index.md` per top-level namespace + and `docs/api/index.md`. The reading order is one chain through all pages: the layers in the order the + config lists them (the order to learn the library), each layer's classes in the order the config names + first and then alphabetically. +4. **Every page starts with YAML front matter**, schema `wptoolkit-api/1`, in a fixed field order, all + strings double-quoted: + + | Field | On | Meaning | + | --- | --- | --- | + | `schema` | all | `"wptoolkit-api/1"`; changes only with an incompatible change to this table | + | `title` | all | The class's short name, or the layer name | + | `type` | all | `class`, `interface`, `enum`, `trait`, `namespace` or `index` | + | `fqcn` | classes | Fully qualified class name | + | `namespace` | classes, layer pages | Its namespace | + | `layer` | all | Top-level namespace under `Codad5\WPToolkit` (empty on the index) | + | `source` | classes | Path of the source file, relative to the repository root | + | `since` | classes | The `@since` tag; `1.0.0` when there is none | + | `stability` | classes | `stable`, or `deprecated` when the class carries `@deprecated` | + | `order` | all | Position in the reading order, from 0, unique | + | `summary` | all | The first paragraph of the docblock, on one line | + | `related` | when any | `{ adr: [7, 13] }`: ADRs the class or its methods cite | + | `prev`, `next`, `up` | when any | `{ title, path }`; `path` is relative to **this page**, so it is also a working Markdown link | + + The footer repeats previous / up / next as ordinary links, for people reading on GitHub. +5. **`docs/api/index.json`** lists every page in reading order with the same facts. Its paths are relative + to `docs/api/` (not to each page), so `prev`, `next` and `up` join directly to other entries' `path`. +6. **The output is deterministic**: no dates, LF line endings, forward slashes, a fixed order. Values that + change with every release (`Application::VERSION`) are listed in the config and printed as + `(set at release)`, so a version bump does not make the reference stale. +7. **No dependencies.** The generator is a PHP script under `tools/` (dev-only, `export-ignore`d). Node's + test job parses the front matter with gray-matter (a dev dependency recorded in `tech-stack.md`) to + prove that real front-matter tooling reads it. + +## Options considered + +- **phpDocumentor** (what the plan named). A large dependency with its own site theme; it does not emit + the machine-readable ordering and links agents need, and its output is not Markdown to commit. +- **Hand-written pages.** The `API.md` failure again. +- **Markdown with no front matter.** An agent would have to parse headings and links to learn what a page + is, where it sits and what comes next. +- **JSON only.** Good for tools, useless for a person browsing GitHub. + +## Consequences + +- A thin docblock shows as a thin page; the reference is also a to-do list for documentation. +- A public method that should not be API must be marked `@internal`. +- A change to `src/` that alters a public signature or docblock fails CI until `composer docs` is run and + the result committed. +- Changing the front matter fields is a schema change: bump `wptoolkit-api/1` and update this table. diff --git a/docs/adr/README.md b/docs/adr/README.md index ff98e28..b42e41d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -58,6 +58,7 @@ Not for a naming choice, a library with no real alternative, or anything you wou | [0022](0022-shortcodes-are-declared-validated-and-lazy.md) | Shortcodes are declared, validated, access-checked and load assets lazily | Accepted | | [0023](0023-settings-may-keep-a-legacy-option-prefix.md) | Settings may keep an option prefix that already exists in users' data | Accepted | | [0024](0024-the-packager-scopes-the-consumers-code-too.md) | The packager scopes the consumer's code and autoloader, not only the library | Accepted | +| [0028](0028-the-api-reference-is-generated-from-the-source.md) | The API reference is generated from the source, with machine-readable front matter | Accepted | | [0027](0027-multiple-choice-fields-render-as-checkboxes-with-an-optional-picker.md) | Multiple-choice fields are checkboxes, with an optional chip picker | Accepted | | [0026](0026-builds-lock-write-atomically-and-clean-up-after-themselves.md) | Builds take a lock, write atomically and clean up after themselves | Accepted | | [0025](0025-the-guard-can-autoload-the-plugins-own-namespaces.md) | The guard can autoload the plugin's own namespaces (completes ADR-0014) | Accepted | diff --git a/docs/api/Foundation/Application.md b/docs/api/Foundation/Application.md new file mode 100644 index 0000000..6dc5bd4 --- /dev/null +++ b/docs/api/Foundation/Application.md @@ -0,0 +1,178 @@ +--- +schema: "wptoolkit-api/1" +title: "Application" +type: "class" +fqcn: "Codad5\\WPToolkit\\Foundation\\Application" +namespace: "Codad5\\WPToolkit\\Foundation" +layer: "Foundation" +source: "src/Foundation/Application.php" +since: "1.0.0" +stability: "stable" +order: 2 +summary: "One consuming plugin or theme. Owns its container, its hooks and its providers; nothing is static, so two plugins never share state (ADR-0007)." +related: { adr: [7, 13, 18, 20] } +prev: { title: "Foundation", path: "index.md" } +next: { title: "ServiceProvider", path: "ServiceProvider.md" } +up: { title: "Foundation", path: "index.md" } +--- + +# Application + +`final class Codad5\WPToolkit\Foundation\Application` + +One consuming plugin or theme. Owns its container, its hooks and its providers; nothing is +static, so two plugins never share state (ADR-0007). + +Lifecycle: + 1. `create()` — builds the config and container. No WordPress calls. + 2. `boot()` — instantiates providers and runs every `register()` straight away. + 3. On `init` (or immediately, if `init` already ran): loads the consumer's text domain, then + runs every provider's `boot()`. Translating at this point can't trigger WordPress 6.7's + "translation loading triggered too early" notice. + 4. `shutdown()` — removes every hook the application added (plugin deactivation). + +Source `src/Foundation/Application.php` · Since 1.0.0 · Decisions: [ADR-0007](../../adr/0007-own-container-no-static-registry.md), [ADR-0013](../../adr/0013-plugins-boot-through-a-syntax-safe-guard.md), [ADR-0018](../../adr/0018-versioned-data-migrations-expand-contract.md), [ADR-0020](../../adr/0020-incompatible-unscoped-copies-refuse-activation.md) + +## Constants + +| Constant | Value | Description | +| --- | --- | --- | +| `VERSION` | `(set at release)` | | + +## Methods + +### `onBooted()` + +```php +public function onBooted(Closure $callback): void; +``` + +Run a callback once every provider has booted — immediately if that already happened. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$callback` | `Closure(): void` | | + +### `isDevelopment()` + +```php +public function isDevelopment(): bool; +``` + +WP_DEBUG on a 'local' or 'development' site: errors surface instead of being contained, and +misconfigurations (such as a route without an access rule) throw at registration. + +**Returns** `bool` + +### `create()` + +```php +public static function create(string $file, array $config): self; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$file` | `string` | The main plugin file (or the theme's functions.php). | +| `$config` | `array` | Must contain a 'slug'. Optional: 'text_domain', 'domain_path' (default 'languages'), 'type' ('plugin' or 'theme', default 'plugin'). | + +**Returns** `self` + +### `providers()` + +```php +public function providers(array $providers): self; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$providers` | `list>` | | + +**Returns** `self` + +### `migrations()` + +```php +public function migrations(array $migrations): self; +``` + +Versioned data migrations (ADR-0018), as objects or class names (built by the container, so +they can take services). They run on activation and on `admin_init` while any is pending. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$migrations` | `list>` | | + +**Returns** `self` + +### `boot()` + +```php +public function boot(): self; +``` + +Register every provider now; boot them on `init`. + +**Returns** `self` + +### `isInert()` + +```php +public function isInert(): bool; +``` + +Whether the application refused to start because the loaded WPToolkit doesn't satisfy +`requires_toolkit` (ADR-0020). + +**Returns** `bool` + +### `shutdown()` + +```php +public function shutdown(): void; +``` + +Remove every hook this application added. Call on plugin deactivation. + +### `config()` + +```php +public function config(): Config; +``` + +**Returns** [`Config`](Config.md) + +### `identity()` + +```php +public function identity(): Identity; +``` + +**Returns** [`Identity`](Identity.md) + +### `container()` + +```php +public function container(): Container; +``` + +**Returns** [`Container`](Container.md) + +### `hooks()` + +```php +public function hooks(): HookRegistrar; +``` + +**Returns** [`HookRegistrar`](HookRegistrar.md) + +### `isBooted()` + +```php +public function isBooted(): bool; +``` + +**Returns** `bool` + +--- + +← [Previous: Foundation](index.md) · [Up: Foundation](index.md) · [Next: ServiceProvider](ServiceProvider.md) → diff --git a/docs/api/Foundation/Coexistence.md b/docs/api/Foundation/Coexistence.md new file mode 100644 index 0000000..f4adb99 --- /dev/null +++ b/docs/api/Foundation/Coexistence.md @@ -0,0 +1,85 @@ +--- +schema: "wptoolkit-api/1" +title: "Coexistence" +type: "class" +fqcn: "Codad5\\WPToolkit\\Foundation\\Coexistence" +namespace: "Codad5\\WPToolkit\\Foundation" +layer: "Foundation" +source: "src/Foundation/Coexistence.php" +since: "1.0.0" +stability: "stable" +order: 8 +summary: "The coexistence ledger: every copy of WPToolkit loaded on the site records itself here, so a plugin can see which copies exist (`toolkit:info`, diagnostics) and explain a conflict." +related: { adr: [5] } +prev: { title: "Container", path: "Container.md" } +next: { title: "CopyOwner", path: "CopyOwner.md" } +up: { title: "Foundation", path: "index.md" } +--- + +# Coexistence + +`final class Codad5\WPToolkit\Foundation\Coexistence` + +The coexistence ledger: every copy of WPToolkit loaded on the site records itself here, so a +plugin can see which copies exist (`toolkit:info`, diagnostics) and explain a conflict. + +This is the one global ADR-0005 allows. Its key is a plain string on purpose: scoping tools +rewrite namespaces, not strings, so every scoped copy writes to the same ledger. + +Source `src/Foundation/Coexistence.php` · Since 1.0.0 · Decisions: [ADR-0005](../../adr/0005-multiple-copies-coexist-via-scoping.md) + +## Constants + +| Constant | Value | Description | +| --- | --- | --- | +| `LEDGER` | `'__wptoolkit_copies'` | | + +## Methods + +### `record()` + +```php +public static function record(string $version, string $path, string $namespace): void; +``` + +Record a copy. Recording the same path again just refreshes it. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$version` | `string` | | +| `$path` | `string` | | +| `$namespace` | `string` | | + +### `copies()` + +```php +public static function copies(): array; +``` + +Every copy recorded so far, keyed by path. + +**Returns** `array` + +### `thisCopyPath()` + +```php +public static function thisCopyPath(): string; +``` + +The package directory of the copy this class belongs to. + +**Returns** `string` + +### `thisCopyNamespace()` + +```php +public static function thisCopyNamespace(): string; +``` + +The root namespace of this copy — `Codad5\WPToolkit` unscoped, `PluginA\WPToolkit` scoped. + +**Returns** `string` + +--- + +← [Previous: Container](Container.md) · [Up: Foundation](index.md) · [Next: CopyOwner](CopyOwner.md) → diff --git a/docs/api/Foundation/Config.md b/docs/api/Foundation/Config.md new file mode 100644 index 0000000..3f07179 --- /dev/null +++ b/docs/api/Foundation/Config.md @@ -0,0 +1,112 @@ +--- +schema: "wptoolkit-api/1" +title: "Config" +type: "class" +fqcn: "Codad5\\WPToolkit\\Foundation\\Config" +namespace: "Codad5\\WPToolkit\\Foundation" +layer: "Foundation" +source: "src/Foundation/Config.php" +since: "1.0.0" +stability: "stable" +order: 5 +summary: "Immutable configuration for one plugin or theme (replaces 0.x `Utils\\Config`)." +prev: { title: "Identity", path: "Identity.md" } +next: { title: "HookRegistrar", path: "HookRegistrar.md" } +up: { title: "Foundation", path: "index.md" } +--- + +# Config + +`final class Codad5\WPToolkit\Foundation\Config` + +Immutable configuration for one plugin or theme (replaces 0.x `Utils\Config`). + +Pure value object: no WordPress calls, no magic accessors. Nested keys are read with dots: +`$config->get('cache.driver')`. + +Source `src/Foundation/Config.php` · Since 1.0.0 + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| `$slug` (readonly) | `string` | | +| `$file` (readonly) | `string` | | + +## Methods + +### `fromArray()` + +```php +public static function fromArray(string $file, array $values): self; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$file` | `string` | The main plugin file (or the theme's functions.php). | +| `$values` | `array` | Must contain a 'slug'. | + +**Returns** `self` + +**Throws** `InvalidConfigException` — When the slug is missing or malformed. + +### `get()` + +```php +public function get(string $key, mixed $default = null): mixed; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$key` | `string` | | +| `$default` | `mixed` | | + +**Returns** `mixed` + +### `has()` + +```php +public function has(string $key): bool; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$key` | `string` | | + +**Returns** `bool` + +### `textDomain()` + +```php +public function textDomain(): string; +``` + +The consumer's text domain: 'text_domain' if set, else the slug. + +**Returns** `string` + +### `with()` + +```php +public function with(array $values): self; +``` + +A copy with the given values merged over this one. The slug and file can't change. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$values` | `array` | | + +**Returns** `self` + +### `all()` + +```php +public function all(): array; +``` + +**Returns** `array` + +--- + +← [Previous: Identity](Identity.md) · [Up: Foundation](index.md) · [Next: HookRegistrar](HookRegistrar.md) → diff --git a/docs/api/Foundation/Container.md b/docs/api/Foundation/Container.md new file mode 100644 index 0000000..79752be --- /dev/null +++ b/docs/api/Foundation/Container.md @@ -0,0 +1,104 @@ +--- +schema: "wptoolkit-api/1" +title: "Container" +type: "class" +fqcn: "Codad5\\WPToolkit\\Foundation\\Container" +namespace: "Codad5\\WPToolkit\\Foundation" +layer: "Foundation" +source: "src/Foundation/Container.php" +since: "1.0.0" +stability: "stable" +order: 7 +summary: "The library's own container (ADR-0007): bindings, singletons, constructor autowiring, method injection, and an error that names the cycle when entries depend on each other." +related: { adr: [7] } +prev: { title: "HookRegistrar", path: "HookRegistrar.md" } +next: { title: "Coexistence", path: "Coexistence.md" } +up: { title: "Foundation", path: "index.md" } +--- + +# Container + +`final class Codad5\WPToolkit\Foundation\Container` — implements `Codad5\WPToolkit\Contracts\Container\Container` + +The library's own container (ADR-0007): bindings, singletons, constructor autowiring, +method injection, and an error that names the cycle when entries depend on each other. + +One instance per Application, so two plugins never share state. + +Source `src/Foundation/Container.php` · Since 1.0.0 · Decisions: [ADR-0007](../../adr/0007-own-container-no-static-registry.md) + +## Methods + +### `get()` + +```php +public function get(string $id): mixed; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$id` | `string` | | + +**Returns** `mixed` + +### `has()` + +```php +public function has(string $id): bool; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$id` | `string` | | + +**Returns** `bool` + +### `bind()` + +```php +public function bind(string $id, ?Closure $factory = null): void; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$id` | `string` | | +| `$factory` | `?Closure` | | + +### `singleton()` + +```php +public function singleton(string $id, ?Closure $factory = null): void; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$id` | `string` | | +| `$factory` | `?Closure` | | + +### `instance()` + +```php +public function instance(string $id, object $instance): void; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$id` | `string` | | +| `$instance` | `object` | | + +### `call()` + +```php +public function call(callable $callable, array $parameters = []): mixed; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$callable` | `callable` | | +| `$parameters` | `array` | | + +**Returns** `mixed` + +--- + +← [Previous: HookRegistrar](HookRegistrar.md) · [Up: Foundation](index.md) · [Next: Coexistence](Coexistence.md) → diff --git a/docs/api/Foundation/CopyOwner.md b/docs/api/Foundation/CopyOwner.md new file mode 100644 index 0000000..a566395 --- /dev/null +++ b/docs/api/Foundation/CopyOwner.md @@ -0,0 +1,45 @@ +--- +schema: "wptoolkit-api/1" +title: "CopyOwner" +type: "class" +fqcn: "Codad5\\WPToolkit\\Foundation\\CopyOwner" +namespace: "Codad5\\WPToolkit\\Foundation" +layer: "Foundation" +source: "src/Foundation/CopyOwner.php" +since: "1.0.0" +stability: "stable" +order: 9 +summary: "Works out which plugin, must-use plugin or theme a WPToolkit copy was loaded from, so a conflict message can name it instead of leaving the administrator to decode a path." +prev: { title: "Coexistence", path: "Coexistence.md" } +next: { title: "CoreServices", path: "CoreServices.md" } +up: { title: "Foundation", path: "index.md" } +--- + +# CopyOwner + +`final class Codad5\WPToolkit\Foundation\CopyOwner` + +Works out which plugin, must-use plugin or theme a WPToolkit copy was loaded from, so a conflict +message can name it instead of leaving the administrator to decode a path. + +Call at render time (admin requests): it may read plugin headers. + +Source `src/Foundation/CopyOwner.php` · Since 1.0.0 + +## Methods + +### `of()` + +```php +public static function of(string $path): ?array; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$path` | `string` | | + +**Returns** `array{type: 'plugin'\|'mu-plugin'\|'theme', name: string, directory: string}\|null` — Null when the path isn't inside a plugin, must-use plugin or theme directory. + +--- + +← [Previous: Coexistence](Coexistence.md) · [Up: Foundation](index.md) · [Next: CoreServices](CoreServices.md) → diff --git a/docs/api/Foundation/CoreServices.md b/docs/api/Foundation/CoreServices.md new file mode 100644 index 0000000..64ae39b --- /dev/null +++ b/docs/api/Foundation/CoreServices.md @@ -0,0 +1,42 @@ +--- +schema: "wptoolkit-api/1" +title: "CoreServices" +type: "class" +fqcn: "Codad5\\WPToolkit\\Foundation\\CoreServices" +namespace: "Codad5\\WPToolkit\\Foundation" +layer: "Foundation" +source: "src/Foundation/CoreServices.php" +since: "1.0.0" +stability: "stable" +order: 10 +summary: "The services every application gets, as lazy singletons — nothing is built until something asks for it. Each can be rebound by a provider's register(), e.g. to swap the HttpClient." +prev: { title: "CopyOwner", path: "CopyOwner.md" } +next: { title: "LibraryTranslations", path: "LibraryTranslations.md" } +up: { title: "Foundation", path: "index.md" } +--- + +# CoreServices + +`final class Codad5\WPToolkit\Foundation\CoreServices` + +The services every application gets, as lazy singletons — nothing is built until something asks +for it. Each can be rebound by a provider's register(), e.g. to swap the HttpClient. + +Source `src/Foundation/CoreServices.php` · Since 1.0.0 + +## Methods + +### `register()` + +```php +public static function register(Application $app, Container $container): void; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$app` | [`Application`](Application.md) | | +| `$container` | [`Container`](Container.md) | | + +--- + +← [Previous: CopyOwner](CopyOwner.md) · [Up: Foundation](index.md) · [Next: LibraryTranslations](LibraryTranslations.md) → diff --git a/docs/api/Foundation/HookRegistrar.md b/docs/api/Foundation/HookRegistrar.md new file mode 100644 index 0000000..b8f5e89 --- /dev/null +++ b/docs/api/Foundation/HookRegistrar.md @@ -0,0 +1,101 @@ +--- +schema: "wptoolkit-api/1" +title: "HookRegistrar" +type: "class" +fqcn: "Codad5\\WPToolkit\\Foundation\\HookRegistrar" +namespace: "Codad5\\WPToolkit\\Foundation" +layer: "Foundation" +source: "src/Foundation/HookRegistrar.php" +since: "1.0.0" +stability: "stable" +order: 6 +summary: "Adds WordPress actions and filters for one plugin and remembers every one, so the plugin can list them (`hooks:list`) and remove all of them on deactivation. Replaces 0.x's `add_tracked_action()`." +related: { adr: [13] } +prev: { title: "Config", path: "Config.md" } +next: { title: "Container", path: "Container.md" } +up: { title: "Foundation", path: "index.md" } +--- + +# HookRegistrar + +`final class Codad5\WPToolkit\Foundation\HookRegistrar` + +Adds WordPress actions and filters for one plugin and remembers every one, so the plugin can +list them (`hooks:list`) and remove all of them on deactivation. Replaces 0.x's +`add_tracked_action()`. + +In containment mode (ADR-0013) each callback is wrapped: an exception is reported and swallowed, +an action simply stops, and a filter returns the value it was given — one broken callback can't +take the page down. On by default in production, off on local/development sites with WP_DEBUG. + +Source `src/Foundation/HookRegistrar.php` · Since 1.0.0 · Decisions: [ADR-0013](../../adr/0013-plugins-boot-through-a-syntax-safe-guard.md) + +## Methods + +### `__construct()` + +```php +public function __construct(bool $contain = false, ?Closure $report = null); +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$contain` | `bool` | Wrap callbacks so exceptions are reported instead of thrown. | +| `$report` | `(Closure(Throwable, string): void)\|null` | Called with the exception and the hook name; defaults to error_log(). | + +### `addAction()` + +```php +public function addAction(string $hook, callable $callback, int $priority = 10, int $acceptedArgs = 1): void; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$hook` | `string` | | +| `$callback` | `callable` | | +| `$priority` | `int` | | +| `$acceptedArgs` | `int` | | + +### `addFilter()` + +```php +public function addFilter(string $hook, callable $callback, int $priority = 10, int $acceptedArgs = 1): void; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$hook` | `string` | | +| `$callback` | `callable` | | +| `$priority` | `int` | | +| `$acceptedArgs` | `int` | | + +### `removeAll()` + +```php +public function removeAll(): void; +``` + +Remove every hook this registrar added, newest first. + +### `all()` + +```php +public function all(): array; +``` + +Every hook currently registered through this registrar, oldest first. `callback` is what the +plugin passed; `registered` is what WordPress holds (a wrapper in containment mode). + +**Returns** `list` + +### `isContaining()` + +```php +public function isContaining(): bool; +``` + +**Returns** `bool` + +--- + +← [Previous: Config](Config.md) · [Up: Foundation](index.md) · [Next: Container](Container.md) → diff --git a/docs/api/Foundation/Identity.md b/docs/api/Foundation/Identity.md new file mode 100644 index 0000000..cf2c221 --- /dev/null +++ b/docs/api/Foundation/Identity.md @@ -0,0 +1,257 @@ +--- +schema: "wptoolkit-api/1" +title: "Identity" +type: "class" +fqcn: "Codad5\\WPToolkit\\Foundation\\Identity" +namespace: "Codad5\\WPToolkit\\Foundation" +layer: "Foundation" +source: "src/Foundation/Identity.php" +since: "1.0.0" +stability: "stable" +order: 4 +summary: "Every name the library writes into WordPress's global namespace, derived from the consumer's slug, so two plugins — or two copies of WPToolkit — never collide (ADR-0005, ADR-0006)." +related: { adr: [5, 6, 16] } +prev: { title: "ServiceProvider", path: "ServiceProvider.md" } +next: { title: "Config", path: "Config.md" } +up: { title: "Foundation", path: "index.md" } +--- + +# Identity + +`final class Codad5\WPToolkit\Foundation\Identity` + +Every name the library writes into WordPress's global namespace, derived from the consumer's +slug, so two plugins — or two copies of WPToolkit — never collide (ADR-0005, ADR-0006). + +Option and meta keys keep 0.x's exact shapes, because sites already store data under them +(ADR-0016). Everything else follows ADR-0006. + +Library code never writes one of these names as a string literal; it asks Identity. + +Source `src/Foundation/Identity.php` · Since 1.0.0 · Decisions: [ADR-0005](../../adr/0005-multiple-copies-coexist-via-scoping.md), [ADR-0006](../../adr/0006-library-text-domain-and-consumer-prefixes.md), [ADR-0016](../../adr/0016-1-0-reads-0x-data-unchanged.md) + +## Constants + +| Constant | Value | Description | +| --- | --- | --- | +| `JS_NAMESPACE` | `'wptoolkit'` | The one shared JavaScript namespace: `window.wptoolkit`. Every plugin's data lives under its own slug key inside it, never at the top level (ADR-0006 amendment). Writers must merge (`window.wptoolkit = window.wptoolkit \|\| {}`), never replace. | + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| `$slug` (readonly) | `string` | | + +## Methods + +### `__construct()` + +```php +public function __construct(string $slug); +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$slug` | `string` | | + +### `hook()` + +```php +public function hook(string $name): string; +``` + +An action or filter name: `my-plugin/http/before_dispatch`. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$name` | `string` | | + +**Returns** `non-empty-string` + +### `ajaxAction()` + +```php +public function ajaxAction(string $name): string; +``` + +An admin-ajax action: `my_plugin_books_search`. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$name` | `string` | | + +**Returns** `non-empty-string` + +### `shortcodeTag()` + +```php +public function shortcodeTag(string $name): string; +``` + +A shortcode tag: `my_plugin_widget`. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$name` | `string` | | + +**Returns** `non-empty-string` + +### `queryVar()` + +```php +public function queryVar(string $name): string; +``` + +A public query variable (rewrite rules): `my_plugin_page`. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$name` | `string` | | + +**Returns** `non-empty-string` + +### `restNamespace()` + +```php +public function restNamespace(string $version = 'v1'): string; +``` + +A REST namespace: `my-plugin/v1`. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$version` | `string` | | + +**Returns** `non-falsy-string` + +### `optionKey()` + +```php +public function optionKey(string $key): string; +``` + +An option name, in 0.x's shape: `{slug}_{sanitize_key(key)}` — e.g. `member-directory_api_key`. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$key` | `string` | | + +**Returns** `non-empty-string` + +**Throws** `InvalidConfigException` — When the result is longer than the options table allows. + +### `transientKey()` + +```php +public function transientKey(string $key, bool $site = false): string; +``` + +A transient name. Long keys are shortened with a hash so they always fit. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$key` | `string` | | +| `$site` | `bool` | | + +**Returns** `non-empty-string` + +### `metaKey()` + +```php +public function metaKey(string $boxId, string $postType, string $field, ?string $prefix = null): string; +``` + +A post meta key, in 0.x MetaBox's shape: `{box_id}_{post_type}_{field}`. + +Pass `$prefix` when the box was given a custom prefix (0.x `set_prefix()`); it then replaces +`{box_id}_{post_type}_` entirely, exactly as 0.x did — including 0.x's sanitize_key() of it, so +`'Acme_'` still means `acme_`. An empty prefix means the bare field name. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$boxId` | `string` | | +| `$postType` | `string` | | +| `$field` | `string` | | +| `$prefix` | `?string` | | + +**Returns** `non-empty-string` + +### `table()` + +```php +public function table(string $name): string; +``` + +A custom table name, without `$wpdb->prefix`: `my_plugin_books`. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$name` | `string` | | + +**Returns** `non-empty-string` + +### `cronHook()` + +```php +public function cronHook(string $name): string; +``` + +A WP-Cron hook: `my_plugin_cleanup`. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$name` | `string` | | + +**Returns** `non-empty-string` + +### `handle()` + +```php +public function handle(string $name): string; +``` + +A script or style handle: `my-plugin-toolkit-api`. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$name` | `string` | | + +**Returns** `non-empty-string` + +### `jsKey()` + +```php +public function jsKey(): string; +``` + +This consumer's key inside `window.wptoolkit` — its slug, unique per site. + +**Returns** `string` + +### `jsAccessor()` + +```php +public function jsAccessor(): string; +``` + +A JavaScript expression for this consumer's data: `window.wptoolkit["my-plugin"]`. + +**Returns** `non-empty-string` + +### `nonceAction()` + +```php +public function nonceAction(string $name): string; +``` + +A nonce action: `my-plugin:books.search`. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$name` | `string` | | + +**Returns** `non-empty-string` + +--- + +← [Previous: ServiceProvider](ServiceProvider.md) · [Up: Foundation](index.md) · [Next: Config](Config.md) → diff --git a/docs/api/Foundation/LibraryTranslations.md b/docs/api/Foundation/LibraryTranslations.md new file mode 100644 index 0000000..5247986 --- /dev/null +++ b/docs/api/Foundation/LibraryTranslations.md @@ -0,0 +1,72 @@ +--- +schema: "wptoolkit-api/1" +title: "LibraryTranslations" +type: "class" +fqcn: "Codad5\\WPToolkit\\Foundation\\LibraryTranslations" +namespace: "Codad5\\WPToolkit\\Foundation" +layer: "Foundation" +source: "src/Foundation/LibraryTranslations.php" +since: "1.0.0" +stability: "stable" +order: 11 +summary: "Loads translations for the library's own strings (text domain `wptoolkit`) from the package's `languages/` directory — wherever the package lives, including a consumer's `vendor/` or a scoped copy (ADR-0006, ADR-0019)." +related: { adr: [6, 19] } +prev: { title: "CoreServices", path: "CoreServices.md" } +next: { title: "Psr11Bridge", path: "Psr11Bridge.md" } +up: { title: "Foundation", path: "index.md" } +--- + +# LibraryTranslations + +`final class Codad5\WPToolkit\Foundation\LibraryTranslations` + +Loads translations for the library's own strings (text domain `wptoolkit`) from the package's +`languages/` directory — wherever the package lives, including a consumer's `vendor/` or a +scoped copy (ADR-0006, ADR-0019). + +Called on `init`, never earlier, so WordPress 6.7's "translation loading triggered too early" +notice can't fire. When another copy of WPToolkit already loaded `wptoolkit`, this one doesn't +load again: identical source strings translate identically, which is the cost ADR-0006 accepts. + +Source `src/Foundation/LibraryTranslations.php` · Since 1.0.0 · Decisions: [ADR-0006](../../adr/0006-library-text-domain-and-consumer-prefixes.md), [ADR-0019](../../adr/0019-no-translator-abstraction.md) + +## Constants + +| Constant | Value | Description | +| --- | --- | --- | +| `DOMAIN` | `'wptoolkit'` | | + +## Methods + +### `__construct()` + +```php +public function __construct(Identity $identity, string $languagesDir); +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$identity` | [`Identity`](Identity.md) | | +| `$languagesDir` | `string` | | + +### `bundledDirectory()` + +```php +public static function bundledDirectory(): string; +``` + +The library's bundled `languages/` directory. + +**Returns** `string` + +### `load()` + +```php +public function load(): bool; +``` + +**Returns** `bool` — Whether a translation file was loaded by this call. + +--- + +← [Previous: CoreServices](CoreServices.md) · [Up: Foundation](index.md) · [Next: Psr11Bridge](Psr11Bridge.md) → diff --git a/docs/api/Foundation/Psr11Bridge.md b/docs/api/Foundation/Psr11Bridge.md new file mode 100644 index 0000000..0ae7b2b --- /dev/null +++ b/docs/api/Foundation/Psr11Bridge.md @@ -0,0 +1,67 @@ +--- +schema: "wptoolkit-api/1" +title: "Psr11Bridge" +type: "class" +fqcn: "Codad5\\WPToolkit\\Foundation\\Psr11Bridge" +namespace: "Codad5\\WPToolkit\\Foundation" +layer: "Foundation" +source: "src/Foundation/Psr11Bridge.php" +since: "1.0.0" +stability: "stable" +order: 12 +summary: "Exposes the application container as PSR-11, for libraries that resolve services from one." +related: { adr: [12] } +prev: { title: "LibraryTranslations", path: "LibraryTranslations.md" } +next: { title: "ToolkitCompatibility", path: "ToolkitCompatibility.md" } +up: { title: "Foundation", path: "index.md" } +--- + +# Psr11Bridge + +`final class Codad5\WPToolkit\Foundation\Psr11Bridge` — implements `ContainerInterface` + +Exposes the application container as PSR-11, for libraries that resolve services from one. + +Only usable when the consumer installs `psr/container` (ADR-0012). + +Source `src/Foundation/Psr11Bridge.php` · Since 1.0.0 · Decisions: [ADR-0012](../../adr/0012-zero-runtime-dependencies.md) + +## Methods + +### `__construct()` + +```php +public function __construct(Container $container); +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$container` | [`Container`](Container.md) | | + +### `get()` + +```php +public function get(string $id): mixed; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$id` | `string` | | + +**Returns** `mixed` + +### `has()` + +```php +public function has(string $id): bool; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$id` | `string` | | + +**Returns** `bool` + +--- + +← [Previous: LibraryTranslations](LibraryTranslations.md) · [Up: Foundation](index.md) · [Next: ToolkitCompatibility](ToolkitCompatibility.md) → diff --git a/docs/api/Foundation/ServiceProvider.md b/docs/api/Foundation/ServiceProvider.md new file mode 100644 index 0000000..42a832a --- /dev/null +++ b/docs/api/Foundation/ServiceProvider.md @@ -0,0 +1,55 @@ +--- +schema: "wptoolkit-api/1" +title: "ServiceProvider" +type: "class" +fqcn: "Codad5\\WPToolkit\\Foundation\\ServiceProvider" +namespace: "Codad5\\WPToolkit\\Foundation" +layer: "Foundation" +source: "src/Foundation/ServiceProvider.php" +since: "1.0.0" +stability: "stable" +order: 3 +summary: "Groups the wiring for one feature." +prev: { title: "Application", path: "Application.md" } +next: { title: "Identity", path: "Identity.md" } +up: { title: "Foundation", path: "index.md" } +--- + +# ServiceProvider + +`abstract class Codad5\WPToolkit\Foundation\ServiceProvider` + +Groups the wiring for one feature. + +- `register()` runs as soon as the application boots: bind services, nothing else. Don't + resolve services or call WordPress here — other providers may not be registered yet. +- `boot()` runs on `init`, after the consumer's text domain is loaded, so it may call `__()`, + add hooks and register routes. Declare any services it needs as parameters; they are + resolved from the container. +- `activate()` / `deactivate()` (optional) run on plugin activation and deactivation, with + their parameters injected the same way. Uninstall needs a static handler instead: pass + `'uninstall' => MyUninstaller::class` in the application config. + +Source `src/Foundation/ServiceProvider.php` · Since 1.0.0 + +## Methods + +### `__construct()` + +```php +public function __construct(Application $app); +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$app` | [`Application`](Application.md) | | + +### `register()` + +```php +public function register(): void; +``` + +--- + +← [Previous: Application](Application.md) · [Up: Foundation](index.md) · [Next: Identity](Identity.md) → diff --git a/docs/api/Foundation/ToolkitCompatibility.md b/docs/api/Foundation/ToolkitCompatibility.md new file mode 100644 index 0000000..32fa241 --- /dev/null +++ b/docs/api/Foundation/ToolkitCompatibility.md @@ -0,0 +1,94 @@ +--- +schema: "wptoolkit-api/1" +title: "ToolkitCompatibility" +type: "class" +fqcn: "Codad5\\WPToolkit\\Foundation\\ToolkitCompatibility" +namespace: "Codad5\\WPToolkit\\Foundation" +layer: "Foundation" +source: "src/Foundation/ToolkitCompatibility.php" +since: "1.0.0" +stability: "stable" +order: 13 +summary: "Checks the consumer's `requires_toolkit` constraint against the WPToolkit copy that is actually loaded, and explains a conflict in terms an administrator can act on (ADR-0005, ADR-0020)." +related: { adr: [5, 20] } +prev: { title: "Psr11Bridge", path: "Psr11Bridge.md" } +next: { title: "Http", path: "../Http/index.md" } +up: { title: "Foundation", path: "index.md" } +--- + +# ToolkitCompatibility + +`final class Codad5\WPToolkit\Foundation\ToolkitCompatibility` + +Checks the consumer's `requires_toolkit` constraint against the WPToolkit copy that is actually +loaded, and explains a conflict in terms an administrator can act on (ADR-0005, ADR-0020). + +With scoping this always passes — each plugin runs its own copy. It exists for unscoped installs, +where PHP loads one copy for everyone and the first one loaded wins. WordPress loads active +plugins in a fixed (alphabetical) order, so "activate this one first" is never offered as a fix. + +Messages translate: build them only at render time, after `init`. + +Source `src/Foundation/ToolkitCompatibility.php` · Since 1.0.0 · Decisions: [ADR-0005](../../adr/0005-multiple-copies-coexist-via-scoping.md), [ADR-0020](../../adr/0020-incompatible-unscoped-copies-refuse-activation.md) + +## Methods + +### `__construct()` + +```php +public function __construct(Config $config); +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$config` | [`Config`](Config.md) | | + +### `isCompatible()` + +```php +public function isCompatible(): bool; +``` + +**Returns** `bool` + +### `problem()` + +```php +public function problem(): string; +``` + +One sentence: what is wrong, naming the plugin or theme whose copy was loaded. + +**Returns** `string` + +### `options()` + +```php +public function options(): array; +``` + +What the administrator (or developer) can do about it, most practical first. + +**Returns** `list` + +### `html()` + +```php +public function html(): string; +``` + +The problem and the options as escaped HTML, for an admin notice or wp_die(). + +**Returns** `string` + +### `pluginName()` + +```php +public function pluginName(): string; +``` + +**Returns** `string` + +--- + +← [Previous: Psr11Bridge](Psr11Bridge.md) · [Up: Foundation](index.md) · [Next: Http](../Http/index.md) → diff --git a/docs/api/Foundation/index.md b/docs/api/Foundation/index.md new file mode 100644 index 0000000..5780b82 --- /dev/null +++ b/docs/api/Foundation/index.md @@ -0,0 +1,35 @@ +--- +schema: "wptoolkit-api/1" +title: "Foundation" +type: "namespace" +namespace: "Codad5\\WPToolkit\\Foundation" +layer: "Foundation" +order: 1 +summary: "The kernel: one Application per plugin, its container and providers, the hooks it adds, and the prefixed names (Identity) everything else is built from." +prev: { title: "API reference", path: "../index.md" } +next: { title: "Application", path: "Application.md" } +up: { title: "API reference", path: "../index.md" } +--- + +# Foundation + +The kernel: one Application per plugin, its container and providers, the hooks it adds, and the prefixed names (Identity) everything else is built from. + +| Page | Kind | Description | +| --- | --- | --- | +| [Application](Application.md) | class | One consuming plugin or theme. Owns its container, its hooks and its providers; nothing is static, so two plugins never share state (ADR-0007). | +| [ServiceProvider](ServiceProvider.md) | class | Groups the wiring for one feature. | +| [Identity](Identity.md) | class | Every name the library writes into WordPress's global namespace, derived from the consumer's slug, so two plugins — or two copies of WPToolkit — never collide (ADR-0005, ADR-0006). | +| [Config](Config.md) | class | Immutable configuration for one plugin or theme (replaces 0.x `Utils\Config`). | +| [HookRegistrar](HookRegistrar.md) | class | Adds WordPress actions and filters for one plugin and remembers every one, so the plugin can list them (`hooks:list`) and remove all of them on deactivation. Replaces 0.x's `add_tracked_action()`. | +| [Container](Container.md) | class | The library's own container (ADR-0007): bindings, singletons, constructor autowiring, method injection, and an error that names the cycle when entries depend on each other. | +| [Coexistence](Coexistence.md) | class | The coexistence ledger: every copy of WPToolkit loaded on the site records itself here, so a plugin can see which copies exist (`toolkit:info`, diagnostics) and explain a conflict. | +| [CopyOwner](CopyOwner.md) | class | Works out which plugin, must-use plugin or theme a WPToolkit copy was loaded from, so a conflict message can name it instead of leaving the administrator to decode a path. | +| [CoreServices](CoreServices.md) | class | The services every application gets, as lazy singletons — nothing is built until something asks for it. Each can be rebound by a provider's register(), e.g. to swap the HttpClient. | +| [LibraryTranslations](LibraryTranslations.md) | class | Loads translations for the library's own strings (text domain `wptoolkit`) from the package's `languages/` directory — wherever the package lives, including a consumer's `vendor/` or a scoped copy (ADR-0006, ADR-0019). | +| [Psr11Bridge](Psr11Bridge.md) | class | Exposes the application container as PSR-11, for libraries that resolve services from one. | +| [ToolkitCompatibility](ToolkitCompatibility.md) | class | Checks the consumer's `requires_toolkit` constraint against the WPToolkit copy that is actually loaded, and explains a conflict in terms an administrator can act on (ADR-0005, ADR-0020). | + +--- + +← [Previous: API reference](../index.md) · [Up: API reference](../index.md) · [Next: Application](Application.md) → diff --git a/docs/api/Http/Client/ApiClient.md b/docs/api/Http/Client/ApiClient.md new file mode 100644 index 0000000..949528c --- /dev/null +++ b/docs/api/Http/Client/ApiClient.md @@ -0,0 +1,155 @@ +--- +schema: "wptoolkit-api/1" +title: "ApiClient" +type: "class" +fqcn: "Codad5\\WPToolkit\\Http\\Client\\ApiClient" +namespace: "Codad5\\WPToolkit\\Http\\Client" +layer: "Http" +source: "src/Http/Client/ApiClient.php" +since: "1.0.0" +stability: "stable" +order: 21 +summary: "A client for one remote API (ports 0.x `APIHelper`): base URL, authentication, JSON, retries with backoff, cached GETs and logging — over any HttpClient (ADR-0004)." +related: { adr: [4] } +prev: { title: "Dispatcher", path: "../Dispatcher.md" } +next: { title: "Auth", path: "Auth.md" } +up: { title: "Http", path: "../index.md" } +--- + +# ApiClient + +`final class Codad5\WPToolkit\Http\Client\ApiClient` + +A client for one remote API (ports 0.x `APIHelper`): base URL, authentication, JSON, retries +with backoff, cached GETs and logging — over any HttpClient (ADR-0004). + +Retries: transport failures, 429 and 5xx, up to `maxRetries`, waiting 2^attempt × base delay +(or the server's Retry-After, capped). 4xx other than 429 is never retried. + +Source `src/Http/Client/ApiClient.php` · Since 1.0.0 · Decisions: [ADR-0004](../../../adr/0004-ports-and-adapters-at-real-seams.md) + +## Methods + +### `__construct()` + +```php +public function __construct(HttpClient $http, string $baseUrl, ?Auth $auth = null, array $headers = [], int $maxRetries = 2, float $retryDelay = 0.5, float $timeout = 10.0, CacheStore $cache = Codad5\WPToolkit\Adapters\Cache\NullStore instance, Logger $logger = Codad5\WPToolkit\Adapters\Log\NullLogger instance, ?Closure $sleep = null); +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$http` | `HttpClient` | | +| `$baseUrl` | `string` | | +| `$auth` | [`?Auth`](Auth.md) | | +| `$headers` | `array` | Sent with every request. | +| `$maxRetries` | `int` | | +| `$retryDelay` | `float` | | +| `$timeout` | `float` | | +| `$cache` | `CacheStore` | | +| `$logger` | `Logger` | | +| `$sleep` | `(Closure(float): void)\|null` | How to wait between retries (seconds); injectable for tests. | + +### `get()` + +```php +public function get(string $path, array $query = [], ?int $cacheTtl = null): HttpResponse; +``` + +GET a path. With `$cacheTtl`, a successful response is cached for that many seconds. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$path` | `string` | | +| `$query` | `array` | | +| `$cacheTtl` | `?int` | | + +**Returns** `HttpResponse` + +### `post()` + +```php +public function post(string $path, array|string|null $body = null): HttpResponse; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$path` | `string` | | +| `$body` | `array\|string\|null` | | + +**Returns** `HttpResponse` + +### `put()` + +```php +public function put(string $path, array|string|null $body = null): HttpResponse; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$path` | `string` | | +| `$body` | `array\|string\|null` | | + +**Returns** `HttpResponse` + +### `patch()` + +```php +public function patch(string $path, array|string|null $body = null): HttpResponse; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$path` | `string` | | +| `$body` | `array\|string\|null` | | + +**Returns** `HttpResponse` + +### `delete()` + +```php +public function delete(string $path): HttpResponse; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$path` | `string` | | + +**Returns** `HttpResponse` + +### `getJson()` + +```php +public function getJson(string $path, array $query = [], ?int $cacheTtl = null): mixed; +``` + +Like get(), but returns the decoded JSON and throws on a non-2xx status. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$path` | `string` | | +| `$query` | `array` | | +| `$cacheTtl` | `?int` | | + +**Returns** `mixed` + +**Throws** `HttpException` + +### `send()` + +```php +public function send(HttpRequest $request): HttpResponse; +``` + +Send with auth, timeout, retries and logging. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$request` | `HttpRequest` | | + +**Returns** `HttpResponse` + +**Throws** `HttpException` — When every attempt failed without a response. + +--- + +← [Previous: Dispatcher](../Dispatcher.md) · [Up: Http](../index.md) · [Next: Auth](Auth.md) → diff --git a/docs/api/Http/Client/Auth.md b/docs/api/Http/Client/Auth.md new file mode 100644 index 0000000..6a393c7 --- /dev/null +++ b/docs/api/Http/Client/Auth.md @@ -0,0 +1,80 @@ +--- +schema: "wptoolkit-api/1" +title: "Auth" +type: "class" +fqcn: "Codad5\\WPToolkit\\Http\\Client\\Auth" +namespace: "Codad5\\WPToolkit\\Http\\Client" +layer: "Http" +source: "src/Http/Client/Auth.php" +since: "1.0.0" +stability: "stable" +order: 22 +summary: "How ApiClient authenticates each request (Strategy)." +prev: { title: "ApiClient", path: "ApiClient.md" } +next: { title: "AjaxTransport", path: "../Transport/AjaxTransport.md" } +up: { title: "Http", path: "../index.md" } +--- + +# Auth + +`abstract class Codad5\WPToolkit\Http\Client\Auth` + +How ApiClient authenticates each request (Strategy). + +Source `src/Http/Client/Auth.php` · Since 1.0.0 + +## Methods + +### `apply()` + +```php +public abstract function apply(HttpRequest $request): HttpRequest; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$request` | `HttpRequest` | | + +**Returns** `HttpRequest` + +### `bearer()` + +```php +public static function bearer(string $token): self; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$token` | `string` | | + +**Returns** `self` + +### `basic()` + +```php +public static function basic(string $username, string $password): self; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$username` | `string` | | +| `$password` | `string` | | + +**Returns** `self` + +### `header()` + +```php +public static function header(string $name, string $value): self; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$name` | `string` | | +| `$value` | `string` | | + +**Returns** `self` + +--- + +← [Previous: ApiClient](ApiClient.md) · [Up: Http](../index.md) · [Next: AjaxTransport](../Transport/AjaxTransport.md) → diff --git a/docs/api/Http/Dispatcher.md b/docs/api/Http/Dispatcher.md new file mode 100644 index 0000000..4a6b053 --- /dev/null +++ b/docs/api/Http/Dispatcher.md @@ -0,0 +1,65 @@ +--- +schema: "wptoolkit-api/1" +title: "Dispatcher" +type: "class" +fqcn: "Codad5\\WPToolkit\\Http\\Dispatcher" +namespace: "Codad5\\WPToolkit\\Http" +layer: "Http" +source: "src/Http/Dispatcher.php" +since: "1.0.0" +stability: "stable" +order: 20 +summary: "The one request pipeline both transports use (ADR-0008), so every security rule is written once:" +related: { adr: [8] } +prev: { title: "HttpError", path: "HttpError.md" } +next: { title: "ApiClient", path: "Client/ApiClient.md" } +up: { title: "Http", path: "index.md" } +--- + +# Dispatcher + +`final class Codad5\WPToolkit\Http\Dispatcher` + +The one request pipeline both transports use (ADR-0008), so every security rule is written once: + + access rule → nonce (Ajax mutations) → rate limit → validation & sanitization → route + middleware → controller → response + +Errors: an HttpError becomes its own status and safe message; anything else is logged with a +request ID and becomes a generic 500. Details reach the client only for administrators with +WP_DEBUG on. + +Source `src/Http/Dispatcher.php` · Since 1.0.0 · Decisions: [ADR-0008](../../adr/0008-deny-by-default-one-http-pipeline.md) + +## Methods + +### `__construct()` + +```php +public function __construct(Container $container, Identity $identity, Logger $logger, ?RateLimiter $rateLimiter = null, bool $developmentMode = false); +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$container` | [`Container`](../Foundation/Container.md) | | +| `$identity` | [`Identity`](../Foundation/Identity.md) | | +| `$logger` | `Logger` | | +| `$rateLimiter` | `?RateLimiter` | | +| `$developmentMode` | `bool` | | + +### `dispatch()` + +```php +public function dispatch(Route $route, Request $request): Response; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$route` | [`Route`](Route.md) | | +| `$request` | [`Request`](Request.md) | | + +**Returns** [`Response`](Response.md) + +--- + +← [Previous: HttpError](HttpError.md) · [Up: Http](index.md) · [Next: ApiClient](Client/ApiClient.md) → diff --git a/docs/api/Http/HttpError.md b/docs/api/Http/HttpError.md new file mode 100644 index 0000000..3422a20 --- /dev/null +++ b/docs/api/Http/HttpError.md @@ -0,0 +1,139 @@ +--- +schema: "wptoolkit-api/1" +title: "HttpError" +type: "class" +fqcn: "Codad5\\WPToolkit\\Http\\HttpError" +namespace: "Codad5\\WPToolkit\\Http" +layer: "Http" +source: "src/Http/HttpError.php" +since: "1.0.0" +stability: "stable" +order: 19 +summary: "An error meant for the client: it carries the HTTP status, a stable machine-readable code and a message safe to show. Anything else thrown by a controller becomes a generic 500 (ADR-0008)." +related: { adr: [8] } +prev: { title: "Response", path: "Response.md" } +next: { title: "Dispatcher", path: "Dispatcher.md" } +up: { title: "Http", path: "index.md" } +--- + +# HttpError + +`class Codad5\WPToolkit\Http\HttpError` — extends `RuntimeException`, implements `Throwable`, `WPToolkitException` + +An error meant for the client: it carries the HTTP status, a stable machine-readable code and a +message safe to show. Anything else thrown by a controller becomes a generic 500 (ADR-0008). + +Throw these from controllers and middleware: `throw HttpError::notFound()`. + +Source `src/Http/HttpError.php` · Since 1.0.0 · Decisions: [ADR-0008](../../adr/0008-deny-by-default-one-http-pipeline.md) + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| `$status` (readonly) | `int` | | +| `$errorCode` (readonly) | `string` | | +| `$details` (readonly) | `array` | Extra, client-safe data (e.g. validation errors). | +| `$headers` (readonly) | `array` | | + +## Methods + +### `__construct()` + +```php +public function __construct(int $status, string $errorCode, string $message, array $details = [], array $headers = [], ?Throwable $previous = null); +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$status` | `int` | | +| `$errorCode` | `string` | | +| `$message` | `string` | | +| `$details` | `array` | Extra, client-safe data (e.g. validation errors). | +| `$headers` | `array` | | +| `$previous` | `?Throwable` | | + +### `badRequest()` + +```php +public static function badRequest(?string $message = null): self; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$message` | `?string` | | + +**Returns** `self` + +### `unauthenticated()` + +```php +public static function unauthenticated(?string $message = null): self; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$message` | `?string` | | + +**Returns** `self` + +### `forbidden()` + +```php +public static function forbidden(?string $message = null): self; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$message` | `?string` | | + +**Returns** `self` + +### `notFound()` + +```php +public static function notFound(?string $message = null): self; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$message` | `?string` | | + +**Returns** `self` + +### `validation()` + +```php +public static function validation(array $errors): self; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$errors` | `array>` | Field => messages. | + +**Returns** `self` + +### `tooManyRequests()` + +```php +public static function tooManyRequests(int $retryAfter, array $headers = []): self; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$retryAfter` | `int` | | +| `$headers` | `array` | | + +**Returns** `self` + +### `internal()` + +```php +public static function internal(): self; +``` + +**Returns** `self` + +--- + +← [Previous: Response](Response.md) · [Up: Http](index.md) · [Next: Dispatcher](Dispatcher.md) → diff --git a/docs/api/Http/Request.md b/docs/api/Http/Request.md new file mode 100644 index 0000000..afd498e --- /dev/null +++ b/docs/api/Http/Request.md @@ -0,0 +1,133 @@ +--- +schema: "wptoolkit-api/1" +title: "Request" +type: "class" +fqcn: "Codad5\\WPToolkit\\Http\\Request" +namespace: "Codad5\\WPToolkit\\Http" +layer: "Http" +source: "src/Http/Request.php" +since: "1.0.0" +stability: "stable" +order: 17 +summary: "An incoming request, the same shape whether it arrived over REST or admin-ajax (immutable)." +prev: { title: "Route", path: "Route.md" } +next: { title: "Response", path: "Response.md" } +up: { title: "Http", path: "index.md" } +--- + +# Request + +`final class Codad5\WPToolkit\Http\Request` + +An incoming request, the same shape whether it arrived over REST or admin-ajax (immutable). + +Input is already unslashed. After validation, `input()` returns the sanitized values the route +declared; undeclared input stays reachable through `raw()` for the rare case that needs it. + +Source `src/Http/Request.php` · Since 1.0.0 + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| `$method` (readonly) | `string` | | +| `$transport` (readonly) | `'rest'\|'ajax'\|'page'` | Where the request came in ('page': a PublicPages URL). | +| `$routeParams` (readonly) | `array` | Values captured from the route path. | +| `$query` (readonly) | `array` | | +| `$body` (readonly) | `array` | | +| `$headers` (readonly) | `array` | Lowercase names. | +| `$files` (readonly) | `array` | | +| `$userId` (readonly) | `int` | | +| `$ip` (readonly) | `string` | | + +## Methods + +### `__construct()` + +```php +public function __construct(string $method, string $transport, array $routeParams = [], array $query = [], array $body = [], array $headers = [], array $files = [], int $userId = 0, string $ip = '0.0.0.0', ?array $validated = null); +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$method` | `string` | | +| `$transport` | `'rest'\|'ajax'\|'page'` | Where the request came in ('page': a PublicPages URL). | +| `$routeParams` | `array` | Values captured from the route path. | +| `$query` | `array` | | +| `$body` | `array` | | +| `$headers` | `array` | Lowercase names. | +| `$files` | `array` | | +| `$userId` | `int` | | +| `$ip` | `string` | | +| `$validated` | `array` | Set by the dispatcher after validation. | + +### `input()` + +```php +public function input(string $key, mixed $default = null): mixed; +``` + +A declared, validated and sanitized input value — route params, then body, then query. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$key` | `string` | | +| `$default` | `mixed` | | + +**Returns** `mixed` + +### `validated()` + +```php +public function validated(): array; +``` + +Every declared, validated and sanitized input. + +**Returns** `array` + +### `raw()` + +```php +public function raw(): array; +``` + +All input as received (unslashed, not sanitized): route params override body override query. + +**Returns** `array` + +### `header()` + +```php +public function header(string $name): ?string; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$name` | `string` | | + +**Returns** `?string` + +### `isLoggedIn()` + +```php +public function isLoggedIn(): bool; +``` + +**Returns** `bool` + +### `withValidated()` + +```php +public function withValidated(array $validated): self; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$validated` | `array` | | + +**Returns** `self` + +--- + +← [Previous: Route](Route.md) · [Up: Http](index.md) · [Next: Response](Response.md) → diff --git a/docs/api/Http/Response.md b/docs/api/Http/Response.md new file mode 100644 index 0000000..54d8a45 --- /dev/null +++ b/docs/api/Http/Response.md @@ -0,0 +1,149 @@ +--- +schema: "wptoolkit-api/1" +title: "Response" +type: "class" +fqcn: "Codad5\\WPToolkit\\Http\\Response" +namespace: "Codad5\\WPToolkit\\Http" +layer: "Http" +source: "src/Http/Response.php" +since: "1.0.0" +stability: "stable" +order: 18 +summary: "An outgoing response, sent as JSON by either transport (immutable)." +prev: { title: "Request", path: "Request.md" } +next: { title: "HttpError", path: "HttpError.md" } +up: { title: "Http", path: "index.md" } +--- + +# Response + +`final class Codad5\WPToolkit\Http\Response` + +An outgoing response, sent as JSON by either transport (immutable). + +Success bodies are the data itself. Error bodies always have the same shape on both transports: +`{ "code": "...", "message": "...", "details": {...}? }`. + +Source `src/Http/Response.php` · Since 1.0.0 + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| `$data` (readonly) | `mixed` | | +| `$status` (readonly) | `int` | | +| `$headers` (readonly) | `array` | | + +## Methods + +### `__construct()` + +```php +public function __construct(mixed $data = null, int $status = 200, array $headers = []); +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$data` | `mixed` | | +| `$status` | `int` | | +| `$headers` | `array` | | + +### `json()` + +```php +public static function json(mixed $data, int $status = 200, array $headers = []): self; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$data` | `mixed` | | +| `$status` | `int` | | +| `$headers` | `array` | | + +**Returns** `self` + +### `created()` + +```php +public static function created(mixed $data = null): self; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$data` | `mixed` | | + +**Returns** `self` + +### `noContent()` + +```php +public static function noContent(): self; +``` + +**Returns** `self` + +### `error()` + +```php +public static function error(int $status, string $code, string $message, array $details = [], array $headers = []): self; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$status` | `int` | | +| `$code` | `string` | | +| `$message` | `string` | | +| `$details` | `array` | | +| `$headers` | `array` | | + +**Returns** `self` + +### `fromError()` + +```php +public static function fromError(HttpError $error): self; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$error` | [`HttpError`](HttpError.md) | | + +**Returns** `self` + +### `from()` + +```php +public static function from(mixed $result): self; +``` + +Turn whatever a controller returned into a Response. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$result` | `mixed` | | + +**Returns** `self` + +### `withHeaders()` + +```php +public function withHeaders(array $headers): self; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$headers` | `array` | | + +**Returns** `self` + +### `isError()` + +```php +public function isError(): bool; +``` + +**Returns** `bool` + +--- + +← [Previous: Request](Request.md) · [Up: Http](index.md) · [Next: HttpError](HttpError.md) → diff --git a/docs/api/Http/Route.md b/docs/api/Http/Route.md new file mode 100644 index 0000000..300761d --- /dev/null +++ b/docs/api/Http/Route.md @@ -0,0 +1,281 @@ +--- +schema: "wptoolkit-api/1" +title: "Route" +type: "class" +fqcn: "Codad5\\WPToolkit\\Http\\Route" +namespace: "Codad5\\WPToolkit\\Http" +layer: "Http" +source: "src/Http/Route.php" +since: "1.0.0" +stability: "stable" +order: 16 +summary: "One route, built fluently (Builder). Declares who may call it — there is no default (ADR-0008) — and how it's exposed." +related: { adr: [8] } +prev: { title: "Router", path: "Router.md" } +next: { title: "Request", path: "Request.md" } +up: { title: "Http", path: "index.md" } +--- + +# Route + +`final class Codad5\WPToolkit\Http\Route` + +One route, built fluently (Builder). Declares who may call it — there is no default +(ADR-0008) — and how it's exposed. + + $router->get('books/{id}', [BookController::class, 'show']) + ->can('read') + ->args(['id' => ['rules' => 'required|integer', 'type' => 'int']]) + ->rateLimit(60, perSeconds: 60) + ->exposeVia('rest', 'ajax'); + +Source `src/Http/Route.php` · Since 1.0.0 · Decisions: [ADR-0008](../../adr/0008-deny-by-default-one-http-pipeline.md) + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| `$methods` (readonly) | `list` | | +| `$path` (readonly) | `string` | | + +## Methods + +### `__construct()` + +```php +public function __construct(array $methods, string $path, mixed $handler); +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$methods` | `list` | | +| `$path` | `string` | | +| `$handler` | `callable\|array{class-string, string}` | | + +### `public()` + +```php +public function public(): self; +``` + +Anyone, including logged-out visitors. The one explicit way to make a route open. + +**Returns** `self` + +### `loggedIn()` + +```php +public function loggedIn(): self; +``` + +**Returns** `self` + +### `can()` + +```php +public function can(string $capability): self; +``` + +Logged in and holding a capability (or meta capability). + +| Parameter | Type | Description | +| --- | --- | --- | +| `$capability` | `string` | | + +**Returns** `self` + +### `authorize()` + +```php +public function authorize(Closure $policy): self; +``` + +Custom authorization, e.g. "may edit this specific post". + +| Parameter | Type | Description | +| --- | --- | --- | +| `$policy` | `Closure(Request): bool` | | + +**Returns** `self` + +### `args()` + +```php +public function args(array $args): self; +``` + +Declared input: only these fields reach `Request::input()`, validated and sanitized. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$args` | `array, type?: string, label?: string, default?: mixed}>` | | + +**Returns** `self` + +### `rateLimit()` + +```php +public function rateLimit(int $max, int $perSeconds = 60, string $by = 'ip'): self; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$max` | `int` | | +| `$perSeconds` | `int` | | +| `$by` | `'ip'\|'user'` | Bucket per client IP or per logged-in user (falls back to IP). | + +**Returns** `self` + +### `exposeVia()` + +```php +public function exposeVia(string ...$transports): self; +``` + +Where the route is reachable: 'rest' (default), 'ajax', or both. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$transports` | `string` | | + +**Returns** `self` + +### `middleware()` + +```php +public function middleware(Middleware|string ...$middleware): self; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$middleware` | `Middleware\|class-string` | | + +**Returns** `self` + +### `version()` + +```php +public function version(string $version): self; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$version` | `string` | | + +**Returns** `self` + +### `name()` + +```php +public function name(string $name): self; +``` + +The name used for the Ajax action and nonce; defaults to the path (`books/{id}` → `books_id`). + +| Parameter | Type | Description | +| --- | --- | --- | +| `$name` | `string` | | + +**Returns** `self` + +### `access()` + +```php +public function access(): ?string; +``` + +**Returns** `?string` + +### `capability()` + +```php +public function capability(): ?string; +``` + +**Returns** `?string` + +### `policy()` + +```php +public function policy(): ?Closure; +``` + +**Returns** `(Closure(Request): bool)\|null` + +### `declaredArgs()` + +```php +public function declaredArgs(): array; +``` + +**Returns** `array, type?: string, label?: string, default?: mixed}>` + +### `rateLimitConfig()` + +```php +public function rateLimitConfig(): ?array; +``` + +**Returns** `array{max: int, seconds: int, by: string}\|null` + +### `transports()` + +```php +public function transports(): array; +``` + +**Returns** `list<'rest'\|'ajax'>` + +### `middlewareStack()` + +```php +public function middlewareStack(): array; +``` + +**Returns** `list>` + +### `apiVersion()` + +```php +public function apiVersion(): string; +``` + +**Returns** `string` + +### `routeName()` + +```php +public function routeName(): string; +``` + +**Returns** `string` + +### `handler()` + +```php +public function handler(): mixed; +``` + +**Returns** `callable\|array{class-string, string}` + +### `isMutation()` + +```php +public function isMutation(): bool; +``` + +**Returns** `bool` + +### `restPattern()` + +```php +public function restPattern(): string; +``` + +The path as a WordPress REST regex: `books/{id}` → `/books/(?P[^/]+)`. + +**Returns** `non-falsy-string` + +--- + +← [Previous: Router](Router.md) · [Up: Http](index.md) · [Next: Request](Request.md) → diff --git a/docs/api/Http/Router.md b/docs/api/Http/Router.md new file mode 100644 index 0000000..908038e --- /dev/null +++ b/docs/api/Http/Router.md @@ -0,0 +1,154 @@ +--- +schema: "wptoolkit-api/1" +title: "Router" +type: "class" +fqcn: "Codad5\\WPToolkit\\Http\\Router" +namespace: "Codad5\\WPToolkit\\Http" +layer: "Http" +source: "src/Http/Router.php" +since: "1.0.0" +stability: "stable" +order: 15 +summary: "Collects routes and registers them with WordPress — REST and/or admin-ajax — through the same Dispatcher (ADR-0008). Replaces 0.x `Ajax` and `RestRoute`." +related: { adr: [8] } +prev: { title: "Http", path: "index.md" } +next: { title: "Route", path: "Route.md" } +up: { title: "Http", path: "index.md" } +--- + +# Router + +`final class Codad5\WPToolkit\Http\Router` + +Collects routes and registers them with WordPress — REST and/or admin-ajax — through the same +Dispatcher (ADR-0008). Replaces 0.x `Ajax` and `RestRoute`. + +Source `src/Http/Router.php` · Since 1.0.0 · Decisions: [ADR-0008](../../adr/0008-deny-by-default-one-http-pipeline.md) + +## Methods + +### `__construct()` + +```php +public function __construct(HookRegistrar $hooks, RestTransport $rest, AjaxTransport $ajax, bool $developmentMode = false); +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$hooks` | [`HookRegistrar`](../Foundation/HookRegistrar.md) | | +| `$rest` | [`RestTransport`](Transport/RestTransport.md) | | +| `$ajax` | [`AjaxTransport`](Transport/AjaxTransport.md) | | +| `$developmentMode` | `bool` | | + +### `get()` + +```php +public function get(string $path, callable|array $handler): Route; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$path` | `string` | | +| `$handler` | `callable\|array{class-string, string}` | | + +**Returns** [`Route`](Route.md) + +### `post()` + +```php +public function post(string $path, callable|array $handler): Route; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$path` | `string` | | +| `$handler` | `callable\|array{class-string, string}` | | + +**Returns** [`Route`](Route.md) + +### `put()` + +```php +public function put(string $path, callable|array $handler): Route; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$path` | `string` | | +| `$handler` | `callable\|array{class-string, string}` | | + +**Returns** [`Route`](Route.md) + +### `patch()` + +```php +public function patch(string $path, callable|array $handler): Route; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$path` | `string` | | +| `$handler` | `callable\|array{class-string, string}` | | + +**Returns** [`Route`](Route.md) + +### `delete()` + +```php +public function delete(string $path, callable|array $handler): Route; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$path` | `string` | | +| `$handler` | `callable\|array{class-string, string}` | | + +**Returns** [`Route`](Route.md) + +### `add()` + +```php +public function add(array $methods, string $path, callable|array $handler): Route; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$methods` | `list` | | +| `$path` | `string` | | +| `$handler` | `callable\|array{class-string, string}` | | + +**Returns** [`Route`](Route.md) + +### `register()` + +```php +public function register(): void; +``` + +Hook the transports. Called once the providers have booted. + +### `clientConfig()` + +```php +public function clientConfig(): array; +``` + +What the JS client needs to call every route by name (`api.call('todos', …)`): URLs, the REST +nonce and, per route, its methods and REST path or Ajax action and nonce. REST is used when a +route offers it. Build this while enqueuing — nonces belong to the current user. + +**Returns** `array{restUrl: string, restNonce: string, ajaxUrl: string, routes: array>}` + +### `routes()` + +```php +public function routes(): array; +``` + +Every route, for `routes:list` and tests. + +**Returns** `list` + +--- + +← [Previous: Http](index.md) · [Up: Http](index.md) · [Next: Route](Route.md) → diff --git a/docs/api/Http/Transport/AjaxTransport.md b/docs/api/Http/Transport/AjaxTransport.md new file mode 100644 index 0000000..1c702c5 --- /dev/null +++ b/docs/api/Http/Transport/AjaxTransport.md @@ -0,0 +1,84 @@ +--- +schema: "wptoolkit-api/1" +title: "AjaxTransport" +type: "class" +fqcn: "Codad5\\WPToolkit\\Http\\Transport\\AjaxTransport" +namespace: "Codad5\\WPToolkit\\Http\\Transport" +layer: "Http" +source: "src/Http/Transport/AjaxTransport.php" +since: "1.0.0" +stability: "stable" +order: 23 +summary: "Exposes routes through admin-ajax as `{slug}_{route}` actions. Logged-out access (`wp_ajax_nopriv_…`) is registered only for `->public()` routes; the Dispatcher still checks." +prev: { title: "Auth", path: "../Client/Auth.md" } +next: { title: "RestTransport", path: "RestTransport.md" } +up: { title: "Http", path: "../index.md" } +--- + +# AjaxTransport + +`final class Codad5\WPToolkit\Http\Transport\AjaxTransport` + +Exposes routes through admin-ajax as `{slug}_{route}` actions. Logged-out access +(`wp_ajax_nopriv_…`) is registered only for `->public()` routes; the Dispatcher still checks. + +Responses send the real HTTP status (0.x always sent 200), with the same body shape as REST. + +Source `src/Http/Transport/AjaxTransport.php` · Since 1.0.0 + +## Methods + +### `__construct()` + +```php +public function __construct(Dispatcher $dispatcher, Identity $identity, HookRegistrar $hooks, ClientIp $clientIp = Codad5\WPToolkit\Support\RateLimit\ClientIp instance, ?Closure $send = null); +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$dispatcher` | [`Dispatcher`](../Dispatcher.md) | | +| `$identity` | [`Identity`](../../Foundation/Identity.md) | | +| `$hooks` | [`HookRegistrar`](../../Foundation/HookRegistrar.md) | | +| `$clientIp` | `ClientIp` | | +| `$send` | `(Closure(Response): void)\|null` | How to emit the response; defaults to wp_send_json(). | + +### `register()` + +```php +public function register(array $routes): void; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$routes` | `list` | | + +### `handle()` + +```php +public function handle(Route $route, Route ...$siblings): void; +``` + +Dispatch to whichever of the action's routes accepts the request method; 405 if none does. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$route` | [`Route`](../Route.md) | | +| `$siblings` | [`Route`](../Route.md) | | + +### `clientConfig()` + +```php +public function clientConfig(Route $route): array; +``` + +The action's name and nonce, for the JS client (Phase 5 localizes these). + +| Parameter | Type | Description | +| --- | --- | --- | +| `$route` | [`Route`](../Route.md) | | + +**Returns** `array{action: string, nonce: string}` + +--- + +← [Previous: Auth](../Client/Auth.md) · [Up: Http](../index.md) · [Next: RestTransport](RestTransport.md) → diff --git a/docs/api/Http/Transport/RestTransport.md b/docs/api/Http/Transport/RestTransport.md new file mode 100644 index 0000000..59f8301 --- /dev/null +++ b/docs/api/Http/Transport/RestTransport.md @@ -0,0 +1,94 @@ +--- +schema: "wptoolkit-api/1" +title: "RestTransport" +type: "class" +fqcn: "Codad5\\WPToolkit\\Http\\Transport\\RestTransport" +namespace: "Codad5\\WPToolkit\\Http\\Transport" +layer: "Http" +source: "src/Http/Transport/RestTransport.php" +since: "1.0.0" +stability: "stable" +order: 24 +summary: "Exposes routes on the WordPress REST API under the consumer's namespace (`my-plugin/v1`)." +related: { adr: [8] } +prev: { title: "AjaxTransport", path: "AjaxTransport.md" } +up: { title: "Http", path: "../index.md" } +--- + +# RestTransport + +`final class Codad5\WPToolkit\Http\Transport\RestTransport` + +Exposes routes on the WordPress REST API under the consumer's namespace (`my-plugin/v1`). + +WordPress's `permission_callback` is left open on purpose: access is decided by the Dispatcher, +which denies a route without a rule (ADR-0008) — one place for the rule on both transports. +WordPress core still verifies the X-WP-Nonce of cookie-authenticated requests before we run. + +Source `src/Http/Transport/RestTransport.php` · Since 1.0.0 · Decisions: [ADR-0008](../../../adr/0008-deny-by-default-one-http-pipeline.md) + +## Methods + +### `__construct()` + +```php +public function __construct(Dispatcher $dispatcher, Identity $identity, ClientIp $clientIp = Codad5\WPToolkit\Support\RateLimit\ClientIp instance); +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$dispatcher` | [`Dispatcher`](../Dispatcher.md) | | +| `$identity` | [`Identity`](../../Foundation/Identity.md) | | +| `$clientIp` | `ClientIp` | | + +### `register()` + +```php +public function register(array $routes): void; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$routes` | `list` | | + +### `clientConfig()` + +```php +public function clientConfig(Route $route): array; +``` + +Where the JS client sends this route: `{namespace}/{path}` under the REST root. + +| Parameter | Type | Description | +| --- | --- | --- | +| `$route` | [`Route`](../Route.md) | | + +**Returns** `array{namespace: string, path: string}` + +### `fromWordPress()` + +```php +public function fromWordPress(WP_REST_Request $request): Request; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$request` | `WP_REST_Request>` | | + +**Returns** [`Request`](../Request.md) + +### `toWordPress()` + +```php +public function toWordPress(Response $response): WP_REST_Response; +``` + +| Parameter | Type | Description | +| --- | --- | --- | +| `$response` | [`Response`](../Response.md) | | + +**Returns** `WP_REST_Response` + +--- + +← [Previous: AjaxTransport](AjaxTransport.md) · [Up: Http](../index.md) diff --git a/docs/api/Http/index.md b/docs/api/Http/index.md new file mode 100644 index 0000000..98cd0bd --- /dev/null +++ b/docs/api/Http/index.md @@ -0,0 +1,33 @@ +--- +schema: "wptoolkit-api/1" +title: "Http" +type: "namespace" +namespace: "Codad5\\WPToolkit\\Http" +layer: "Http" +order: 14 +summary: "One pipeline for REST, admin-ajax and public pages: the router and its fluent routes, requests and responses, and the dispatcher that enforces access, validation and rate limits." +prev: { title: "ToolkitCompatibility", path: "../Foundation/ToolkitCompatibility.md" } +next: { title: "Router", path: "Router.md" } +up: { title: "API reference", path: "../index.md" } +--- + +# Http + +One pipeline for REST, admin-ajax and public pages: the router and its fluent routes, requests and responses, and the dispatcher that enforces access, validation and rate limits. + +| Page | Kind | Description | +| --- | --- | --- | +| [Router](Router.md) | class | Collects routes and registers them with WordPress — REST and/or admin-ajax — through the same Dispatcher (ADR-0008). Replaces 0.x `Ajax` and `RestRoute`. | +| [Route](Route.md) | class | One route, built fluently (Builder). Declares who may call it — there is no default (ADR-0008) — and how it's exposed. | +| [Request](Request.md) | class | An incoming request, the same shape whether it arrived over REST or admin-ajax (immutable). | +| [Response](Response.md) | class | An outgoing response, sent as JSON by either transport (immutable). | +| [HttpError](HttpError.md) | class | An error meant for the client: it carries the HTTP status, a stable machine-readable code and a message safe to show. Anything else thrown by a controller becomes a generic 500 (ADR-0008). | +| [Dispatcher](Dispatcher.md) | class | The one request pipeline both transports use (ADR-0008), so every security rule is written once: | +| [ApiClient](Client/ApiClient.md) | class | A client for one remote API (ports 0.x `APIHelper`): base URL, authentication, JSON, retries with backoff, cached GETs and logging — over any HttpClient (ADR-0004). | +| [Auth](Client/Auth.md) | class | How ApiClient authenticates each request (Strategy). | +| [AjaxTransport](Transport/AjaxTransport.md) | class | Exposes routes through admin-ajax as `{slug}_{route}` actions. Logged-out access (`wp_ajax_nopriv_…`) is registered only for `->public()` routes; the Dispatcher still checks. | +| [RestTransport](Transport/RestTransport.md) | class | Exposes routes on the WordPress REST API under the consumer's namespace (`my-plugin/v1`). | + +--- + +← [Previous: ToolkitCompatibility](../Foundation/ToolkitCompatibility.md) · [Up: API reference](../index.md) · [Next: Router](Router.md) → diff --git a/docs/api/index.json b/docs/api/index.json new file mode 100644 index 0000000..8798302 --- /dev/null +++ b/docs/api/index.json @@ -0,0 +1,330 @@ +{ + "schema": "wptoolkit-api/1", + "pages": [ + { + "path": "index.md", + "title": "API reference", + "type": "index", + "fqcn": null, + "layer": "", + "order": 0, + "since": null, + "summary": "Every public class of WPToolkit, in the order to read them.", + "prev": null, + "next": "Foundation/index.md", + "up": null + }, + { + "path": "Foundation/index.md", + "title": "Foundation", + "type": "namespace", + "fqcn": null, + "layer": "Foundation", + "order": 1, + "since": null, + "summary": "The kernel: one Application per plugin, its container and providers, the hooks it adds, and the prefixed names (Identity) everything else is built from.", + "prev": "index.md", + "next": "Foundation/Application.md", + "up": "index.md" + }, + { + "path": "Foundation/Application.md", + "title": "Application", + "type": "class", + "fqcn": "Codad5\\WPToolkit\\Foundation\\Application", + "layer": "Foundation", + "order": 2, + "since": "1.0.0", + "summary": "One consuming plugin or theme. Owns its container, its hooks and its providers; nothing is static, so two plugins never share state (ADR-0007).", + "prev": "Foundation/index.md", + "next": "Foundation/ServiceProvider.md", + "up": "Foundation/index.md" + }, + { + "path": "Foundation/ServiceProvider.md", + "title": "ServiceProvider", + "type": "class", + "fqcn": "Codad5\\WPToolkit\\Foundation\\ServiceProvider", + "layer": "Foundation", + "order": 3, + "since": "1.0.0", + "summary": "Groups the wiring for one feature.", + "prev": "Foundation/Application.md", + "next": "Foundation/Identity.md", + "up": "Foundation/index.md" + }, + { + "path": "Foundation/Identity.md", + "title": "Identity", + "type": "class", + "fqcn": "Codad5\\WPToolkit\\Foundation\\Identity", + "layer": "Foundation", + "order": 4, + "since": "1.0.0", + "summary": "Every name the library writes into WordPress's global namespace, derived from the consumer's slug, so two plugins — or two copies of WPToolkit — never collide (ADR-0005, ADR-0006).", + "prev": "Foundation/ServiceProvider.md", + "next": "Foundation/Config.md", + "up": "Foundation/index.md" + }, + { + "path": "Foundation/Config.md", + "title": "Config", + "type": "class", + "fqcn": "Codad5\\WPToolkit\\Foundation\\Config", + "layer": "Foundation", + "order": 5, + "since": "1.0.0", + "summary": "Immutable configuration for one plugin or theme (replaces 0.x `Utils\\Config`).", + "prev": "Foundation/Identity.md", + "next": "Foundation/HookRegistrar.md", + "up": "Foundation/index.md" + }, + { + "path": "Foundation/HookRegistrar.md", + "title": "HookRegistrar", + "type": "class", + "fqcn": "Codad5\\WPToolkit\\Foundation\\HookRegistrar", + "layer": "Foundation", + "order": 6, + "since": "1.0.0", + "summary": "Adds WordPress actions and filters for one plugin and remembers every one, so the plugin can list them (`hooks:list`) and remove all of them on deactivation. Replaces 0.x's `add_tracked_action()`.", + "prev": "Foundation/Config.md", + "next": "Foundation/Container.md", + "up": "Foundation/index.md" + }, + { + "path": "Foundation/Container.md", + "title": "Container", + "type": "class", + "fqcn": "Codad5\\WPToolkit\\Foundation\\Container", + "layer": "Foundation", + "order": 7, + "since": "1.0.0", + "summary": "The library's own container (ADR-0007): bindings, singletons, constructor autowiring, method injection, and an error that names the cycle when entries depend on each other.", + "prev": "Foundation/HookRegistrar.md", + "next": "Foundation/Coexistence.md", + "up": "Foundation/index.md" + }, + { + "path": "Foundation/Coexistence.md", + "title": "Coexistence", + "type": "class", + "fqcn": "Codad5\\WPToolkit\\Foundation\\Coexistence", + "layer": "Foundation", + "order": 8, + "since": "1.0.0", + "summary": "The coexistence ledger: every copy of WPToolkit loaded on the site records itself here, so a plugin can see which copies exist (`toolkit:info`, diagnostics) and explain a conflict.", + "prev": "Foundation/Container.md", + "next": "Foundation/CopyOwner.md", + "up": "Foundation/index.md" + }, + { + "path": "Foundation/CopyOwner.md", + "title": "CopyOwner", + "type": "class", + "fqcn": "Codad5\\WPToolkit\\Foundation\\CopyOwner", + "layer": "Foundation", + "order": 9, + "since": "1.0.0", + "summary": "Works out which plugin, must-use plugin or theme a WPToolkit copy was loaded from, so a conflict message can name it instead of leaving the administrator to decode a path.", + "prev": "Foundation/Coexistence.md", + "next": "Foundation/CoreServices.md", + "up": "Foundation/index.md" + }, + { + "path": "Foundation/CoreServices.md", + "title": "CoreServices", + "type": "class", + "fqcn": "Codad5\\WPToolkit\\Foundation\\CoreServices", + "layer": "Foundation", + "order": 10, + "since": "1.0.0", + "summary": "The services every application gets, as lazy singletons — nothing is built until something asks for it. Each can be rebound by a provider's register(), e.g. to swap the HttpClient.", + "prev": "Foundation/CopyOwner.md", + "next": "Foundation/LibraryTranslations.md", + "up": "Foundation/index.md" + }, + { + "path": "Foundation/LibraryTranslations.md", + "title": "LibraryTranslations", + "type": "class", + "fqcn": "Codad5\\WPToolkit\\Foundation\\LibraryTranslations", + "layer": "Foundation", + "order": 11, + "since": "1.0.0", + "summary": "Loads translations for the library's own strings (text domain `wptoolkit`) from the package's `languages/` directory — wherever the package lives, including a consumer's `vendor/` or a scoped copy (ADR-0006, ADR-0019).", + "prev": "Foundation/CoreServices.md", + "next": "Foundation/Psr11Bridge.md", + "up": "Foundation/index.md" + }, + { + "path": "Foundation/Psr11Bridge.md", + "title": "Psr11Bridge", + "type": "class", + "fqcn": "Codad5\\WPToolkit\\Foundation\\Psr11Bridge", + "layer": "Foundation", + "order": 12, + "since": "1.0.0", + "summary": "Exposes the application container as PSR-11, for libraries that resolve services from one.", + "prev": "Foundation/LibraryTranslations.md", + "next": "Foundation/ToolkitCompatibility.md", + "up": "Foundation/index.md" + }, + { + "path": "Foundation/ToolkitCompatibility.md", + "title": "ToolkitCompatibility", + "type": "class", + "fqcn": "Codad5\\WPToolkit\\Foundation\\ToolkitCompatibility", + "layer": "Foundation", + "order": 13, + "since": "1.0.0", + "summary": "Checks the consumer's `requires_toolkit` constraint against the WPToolkit copy that is actually loaded, and explains a conflict in terms an administrator can act on (ADR-0005, ADR-0020).", + "prev": "Foundation/Psr11Bridge.md", + "next": "Http/index.md", + "up": "Foundation/index.md" + }, + { + "path": "Http/index.md", + "title": "Http", + "type": "namespace", + "fqcn": null, + "layer": "Http", + "order": 14, + "since": null, + "summary": "One pipeline for REST, admin-ajax and public pages: the router and its fluent routes, requests and responses, and the dispatcher that enforces access, validation and rate limits.", + "prev": "Foundation/ToolkitCompatibility.md", + "next": "Http/Router.md", + "up": "index.md" + }, + { + "path": "Http/Router.md", + "title": "Router", + "type": "class", + "fqcn": "Codad5\\WPToolkit\\Http\\Router", + "layer": "Http", + "order": 15, + "since": "1.0.0", + "summary": "Collects routes and registers them with WordPress — REST and/or admin-ajax — through the same Dispatcher (ADR-0008). Replaces 0.x `Ajax` and `RestRoute`.", + "prev": "Http/index.md", + "next": "Http/Route.md", + "up": "Http/index.md" + }, + { + "path": "Http/Route.md", + "title": "Route", + "type": "class", + "fqcn": "Codad5\\WPToolkit\\Http\\Route", + "layer": "Http", + "order": 16, + "since": "1.0.0", + "summary": "One route, built fluently (Builder). Declares who may call it — there is no default (ADR-0008) — and how it's exposed.", + "prev": "Http/Router.md", + "next": "Http/Request.md", + "up": "Http/index.md" + }, + { + "path": "Http/Request.md", + "title": "Request", + "type": "class", + "fqcn": "Codad5\\WPToolkit\\Http\\Request", + "layer": "Http", + "order": 17, + "since": "1.0.0", + "summary": "An incoming request, the same shape whether it arrived over REST or admin-ajax (immutable).", + "prev": "Http/Route.md", + "next": "Http/Response.md", + "up": "Http/index.md" + }, + { + "path": "Http/Response.md", + "title": "Response", + "type": "class", + "fqcn": "Codad5\\WPToolkit\\Http\\Response", + "layer": "Http", + "order": 18, + "since": "1.0.0", + "summary": "An outgoing response, sent as JSON by either transport (immutable).", + "prev": "Http/Request.md", + "next": "Http/HttpError.md", + "up": "Http/index.md" + }, + { + "path": "Http/HttpError.md", + "title": "HttpError", + "type": "class", + "fqcn": "Codad5\\WPToolkit\\Http\\HttpError", + "layer": "Http", + "order": 19, + "since": "1.0.0", + "summary": "An error meant for the client: it carries the HTTP status, a stable machine-readable code and a message safe to show. Anything else thrown by a controller becomes a generic 500 (ADR-0008).", + "prev": "Http/Response.md", + "next": "Http/Dispatcher.md", + "up": "Http/index.md" + }, + { + "path": "Http/Dispatcher.md", + "title": "Dispatcher", + "type": "class", + "fqcn": "Codad5\\WPToolkit\\Http\\Dispatcher", + "layer": "Http", + "order": 20, + "since": "1.0.0", + "summary": "The one request pipeline both transports use (ADR-0008), so every security rule is written once:", + "prev": "Http/HttpError.md", + "next": "Http/Client/ApiClient.md", + "up": "Http/index.md" + }, + { + "path": "Http/Client/ApiClient.md", + "title": "ApiClient", + "type": "class", + "fqcn": "Codad5\\WPToolkit\\Http\\Client\\ApiClient", + "layer": "Http", + "order": 21, + "since": "1.0.0", + "summary": "A client for one remote API (ports 0.x `APIHelper`): base URL, authentication, JSON, retries with backoff, cached GETs and logging — over any HttpClient (ADR-0004).", + "prev": "Http/Dispatcher.md", + "next": "Http/Client/Auth.md", + "up": "Http/index.md" + }, + { + "path": "Http/Client/Auth.md", + "title": "Auth", + "type": "class", + "fqcn": "Codad5\\WPToolkit\\Http\\Client\\Auth", + "layer": "Http", + "order": 22, + "since": "1.0.0", + "summary": "How ApiClient authenticates each request (Strategy).", + "prev": "Http/Client/ApiClient.md", + "next": "Http/Transport/AjaxTransport.md", + "up": "Http/index.md" + }, + { + "path": "Http/Transport/AjaxTransport.md", + "title": "AjaxTransport", + "type": "class", + "fqcn": "Codad5\\WPToolkit\\Http\\Transport\\AjaxTransport", + "layer": "Http", + "order": 23, + "since": "1.0.0", + "summary": "Exposes routes through admin-ajax as `{slug}_{route}` actions. Logged-out access (`wp_ajax_nopriv_…`) is registered only for `->public()` routes; the Dispatcher still checks.", + "prev": "Http/Client/Auth.md", + "next": "Http/Transport/RestTransport.md", + "up": "Http/index.md" + }, + { + "path": "Http/Transport/RestTransport.md", + "title": "RestTransport", + "type": "class", + "fqcn": "Codad5\\WPToolkit\\Http\\Transport\\RestTransport", + "layer": "Http", + "order": 24, + "since": "1.0.0", + "summary": "Exposes routes on the WordPress REST API under the consumer's namespace (`my-plugin/v1`).", + "prev": "Http/Transport/AjaxTransport.md", + "next": null, + "up": "Http/index.md" + } + ] +} diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..8fa55f7 --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,22 @@ +--- +schema: "wptoolkit-api/1" +title: "API reference" +type: "index" +layer: "" +order: 0 +summary: "Every public class of WPToolkit, in the order to read them." +next: { title: "Foundation", path: "Foundation/index.md" } +--- + +# API reference + +Generated from the source by `composer docs`; do not edit these files. Each page lists a class's public API with the reasoning from its docblocks. Pages carry machine-readable front matter (schema `wptoolkit-api/1`, [ADR-0028](../adr/0028-the-api-reference-is-generated-from-the-source.md)) and `index.json` lists them all in reading order. + +| Page | Kind | Description | +| --- | --- | --- | +| [Foundation](Foundation/index.md) | namespace | The kernel: one Application per plugin, its container and providers, the hooks it adds, and the prefixed names (Identity) everything else is built from. | +| [Http](Http/index.md) | namespace | One pipeline for REST, admin-ajax and public pages: the router and its fluent routes, requests and responses, and the dispatcher that enforces access, validation and rate limits. | + +--- + +[Next: Foundation](Foundation/index.md) → diff --git a/docs/phases/phase-6-dx-docs-ai.md b/docs/phases/phase-6-dx-docs-ai.md index 4029a11..a32bee3 100644 --- a/docs/phases/phase-6-dx-docs-ai.md +++ b/docs/phases/phase-6-dx-docs-ai.md @@ -25,8 +25,10 @@ plugin quickly, because the safe path is the default path and it's written down ### 6.2 Documentation -- [ ] API reference generated from docblocks (phpDocumentor) in CI — **deferred to 1.1**: the guides - and `llms.txt` cover the public API; phpDocumentor adds a large toolchain for little gain now +- [ ] API reference generated from docblocks — **in progress** (started 2026-10-18), without phpDocumentor + ([ADR-0028](../adr/0028-the-api-reference-is-generated-from-the-source.md)): `composer docs` writes + `docs/api/` with machine-readable front matter, `composer docs:check` and a CI job keep it current. + Foundation and Http are published; the other namespaces follow as their docblocks are reviewed. - [x] Guides: [getting started](../guides/getting-started.md), [routes and security](../guides/routes-and-security.md), [data](../guides/data.md), [admin and front end](../guides/admin-and-front-end.md) (views, assets and localization), [scoping and coexistence](../guides/scoping-and-coexistence.md). diff --git a/docs/reference/tech-stack.md b/docs/reference/tech-stack.md index ac79464..5a58598 100644 --- a/docs/reference/tech-stack.md +++ b/docs/reference/tech-stack.md @@ -35,6 +35,7 @@ Every dev dependency has a one-line justification. **Runtime dependencies: none* | `@wordpress/env` | _tbd_ | Local and CI WordPress in Docker | | `@wordpress/scripts` | _tbd_ | Build toolkit JS, asset manifests | | `@playwright/test` | _tbd_ | E2E | +| `gray-matter` | 4.0.3 | Parses the API reference's front matter in `tests/docs` (ADR-0028). Dev only. `npm audit` flags it only through `js-yaml`, which `@wordpress/env` already brings; it reads our own generated files | | `vitest` | _tbd_ | JS unit tests | | WP-CLI `i18n` command | _tbd_ | `make-pot`, `make-json` | | Strauss | _tbd_ | Scoping for Composer consumers (documented, used in fixtures) | diff --git a/docs/reference/work-log.md b/docs/reference/work-log.md index 8f927c3..e025ad6 100644 --- a/docs/reference/work-log.md +++ b/docs/reference/work-log.md @@ -68,3 +68,11 @@ One dated entry per working session: what landed, what was learned, what's next. The packager tests also run clean on Linux (WSL), including a real SIGINT. - **Noticed, not fixed:** the `media` field renders `data-wptoolkit-media` for an admin script that does not exist yet, so its "Choose media" button does nothing. + +## 2026-10-18 + +- **Landed:** the generated API reference (ADR-0028): `composer docs` writes `docs/api/` (Foundation and Http first) with + YAML front matter, one reading-order chain with previous/up/next, and `index.json`; `composer docs:check` and a CI job keep + it current, and gray-matter parses it in a Node test. **Learned:** linking by class name returned the path instead of the + class, so `implements`/`extends` were never links until a test with a fixture caught it. +- **Next:** review the remaining namespaces' docblocks, mark wiring `@internal`, and add them to `tools/apidocs/config.json`. diff --git a/llms.txt b/llms.txt index 04fea40..43ed5e9 100644 --- a/llms.txt +++ b/llms.txt @@ -36,6 +36,7 @@ Namespace: `Codad5\WPToolkit` (or your own, if you scope the library). Package: ## Docs +- [API reference](docs/api/index.md): every public class, in reading order; YAML front matter and `docs/api/index.json` give each page's type, class name, order and previous/next. Generated by `composer docs`. - [README](README.md): what it is, install, quick start - [Getting started](docs/guides/getting-started.md) - [Routes and security](docs/guides/routes-and-security.md) diff --git a/package-lock.json b/package-lock.json index ddd2d87..e8b647e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "license": "GPL-2.0-or-later", "devDependencies": { "@playwright/test": "^1.63.0", - "@wordpress/env": "^11.17.0" + "@wordpress/env": "^11.17.0", + "gray-matter": "4.0.3" } }, "node_modules/@inquirer/ansi": { @@ -2700,6 +2701,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3009,6 +3023,22 @@ "dev": true, "license": "ISC" }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3214,6 +3244,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -3430,6 +3470,16 @@ "json-buffer": "3.0.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -4268,6 +4318,20 @@ "node": ">=11.0.0" } }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -4701,6 +4765,16 @@ "node": ">=8" } }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strnum": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz", diff --git a/package.json b/package.json index c1234f8..f502e02 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "devDependencies": { "@playwright/test": "^1.63.0", - "@wordpress/env": "^11.17.0" + "@wordpress/env": "^11.17.0", + "gray-matter": "4.0.3" } } diff --git a/phpcs.xml.dist b/phpcs.xml.dist index f5ef9a5..bfdce71 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -6,6 +6,7 @@ bin bootstrap packages/build/src + tools/apidocs/src @@ -61,6 +62,7 @@ bin/* packages/build/* + tools/* diff --git a/phpstan.neon.dist b/phpstan.neon.dist index faae650..4e11241 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -6,6 +6,7 @@ parameters: paths: - src - packages/build/src + - tools/apidocs/src - examples/todo/src tmpDir: .phpstan.cache # WP-CLI's helper functions (src/Cli) live in a file Composer doesn't autoload. diff --git a/src/Frontend/Shortcode.php b/src/Frontend/Shortcode.php index 9c630e9..90f5f91 100644 --- a/src/Frontend/Shortcode.php +++ b/src/Frontend/Shortcode.php @@ -21,6 +21,8 @@ * ->attributes(['count' => ['type' => 'int', 'default' => 10, 'rules' => 'integer|min:1|max:50']]) * ->view('front/widget') * ->assets('widget'); + * + * @since 1.0.0-rc.2 */ final class Shortcode { diff --git a/src/Frontend/Shortcodes.php b/src/Frontend/Shortcodes.php index b9b7574..61f9126 100644 --- a/src/Frontend/Shortcodes.php +++ b/src/Frontend/Shortcodes.php @@ -24,6 +24,8 @@ * Shortcodes done safely (ADR-0022): a prefixed tag, declared and validated attributes, an explicit * access rule, a view or a returned string (never an echo), assets that load only where the * shortcode appears, and a handler that cannot take the page down. + * + * @since 1.0.0-rc.2 */ final class Shortcodes { diff --git a/tests/Fixtures/ApiDocs/docs/adr/0007-fixture.md b/tests/Fixtures/ApiDocs/docs/adr/0007-fixture.md new file mode 100644 index 0000000..b93dfd2 --- /dev/null +++ b/tests/Fixtures/ApiDocs/docs/adr/0007-fixture.md @@ -0,0 +1 @@ +# ADR-0007 fixture diff --git a/tests/Fixtures/ApiDocs/src/Widgets/Deep/Nested.php b/tests/Fixtures/ApiDocs/src/Widgets/Deep/Nested.php new file mode 100644 index 0000000..9baac82 --- /dev/null +++ b/tests/Fixtures/ApiDocs/src/Widgets/Deep/Nested.php @@ -0,0 +1,21 @@ + $sizes Part sizes by name. + * @param (Closure(string): void)|null $notify Called with each name. + */ + public function __construct(public readonly array $sizes = [], private readonly ?Closure $notify = null) + { + } + + /** + * Assemble the gadget. + * + * @param string $name What to call it. + * @param int|string $count How many, as a number + * or a word. + * @return list The part names. + * @throws InvalidArgumentException When the name is empty. + */ + public static function assemble(string $name, int|string $count = 3, bool $strict = false): array + { + return []; + } + + /** + * @internal Hooked to init. + */ + public function onInit(): void + { + } + + public function label(): string + { + return 'gadget'; + } + + public function __toString(): string + { + return ''; + } + + private function secret(): void + { + } +} diff --git a/tests/Fixtures/ApiDocs/src/Widgets/Hidden.php b/tests/Fixtures/ApiDocs/src/Widgets/Hidden.php new file mode 100644 index 0000000..8b97835 --- /dev/null +++ b/tests/Fixtures/ApiDocs/src/Widgets/Hidden.php @@ -0,0 +1,14 @@ +root = sys_get_temp_dir() . '/wptk-apidocs-' . bin2hex(random_bytes(4)); + $this->output = $this->root . '/out'; + $this->copy(self::FIXTURE, $this->root); + // The fixture classes live outside the library's own PSR-4 map. + $this->autoloader = static function (string $class): void { + $prefix = 'Codad5\\WPToolkit\\Widgets\\'; + if (str_starts_with($class, $prefix)) { + $file = self::FIXTURE . '/src/Widgets/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php'; + if (is_file($file)) { + require_once $file; + } + } + }; + spl_autoload_register($this->autoloader); + } + + protected function tearDown(): void + { + spl_autoload_unregister($this->autoloader); + if (is_dir($this->root)) { + $items = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->root, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($items as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($this->root); + } + } + + // --- DocBlock ------------------------------------------------------------------------------- + + public function test_a_docblock_is_split_into_summary_prose_and_tags(): void + { + $doc = DocBlock::parse("/**\n * First line\n * wraps here.\n *\n * Second paragraph.\n *\n * @since 1.2.0\n * @internal Hooked to init.\n */"); + + self::assertSame('First line wraps here.', $doc->summary); + self::assertStringContainsString('Second paragraph.', $doc->prose()); + self::assertStringNotContainsString('@since', $doc->prose()); + self::assertSame('1.2.0', $doc->first('since')['text'] ?? null); + self::assertTrue($doc->has('internal')); + } + + /** + * @dataProvider typesWithSpaces + */ + public function test_types_containing_spaces_are_read_whole(string $tag, string $type, string $variable, string $text): void + { + $param = DocBlock::parse("/**\n * @param " . $tag . "\n */")->first('param'); + + self::assertNotNull($param); + self::assertSame($type, $param['type']); + self::assertSame($variable, $param['variable']); + self::assertSame($text, $param['text']); + } + + /** + * @return array + */ + public static function typesWithSpaces(): array + { + return [ + 'generic' => ['array $input Raw values.', 'array', '$input', 'Raw values.'], + 'callable' => ['Closure(string): void $callback Called.', 'Closure(string): void', '$callback', 'Called.'], + 'optional callable' => ['(Closure(string): void)|null $cb When set.', '(Closure(string): void)|null', '$cb', 'When set.'], + 'shape' => ['array{slug: string, name?: string} $config Settings.', 'array{slug: string, name?: string}', '$config', 'Settings.'], + 'union with spaces around the bar' => ['int | string $count A number.', 'int | string', '$count', 'A number.'], + 'continued on the next line' => ["string \$name What to call it\n * or something else.", 'string', '$name', 'What to call it or something else.'], + 'variadic' => ['string ...$names Several.', 'string', '$names', 'Several.'], + ]; + } + + public function test_a_missing_docblock_is_an_empty_one(): void + { + $doc = DocBlock::parse(false); + + self::assertSame('', $doc->summary); + self::assertSame([], $doc->tags); + } + + // --- What is documented ----------------------------------------------------------------------- + + public function test_public_api_is_documented_and_internals_are_left_out(): void + { + $files = $this->generate(); + + self::assertArrayHasKey('Widgets/Gadget.md', $files); + self::assertArrayHasKey('Widgets/Part.md', $files); + self::assertArrayHasKey('Widgets/Mode.md', $files); + self::assertArrayHasKey('Widgets/Deep/Nested.md', $files); + self::assertArrayNotHasKey('Widgets/Hidden.md', $files, 'a class marked @internal is wiring, not API'); + + $gadget = $files['Widgets/Gadget.md']; + self::assertStringContainsString('### `assemble()`', $gadget); + self::assertStringNotContainsString('onInit', $gadget, 'an @internal method is not API'); + self::assertStringNotContainsString('__toString', $gadget, 'magic methods are not documented'); + self::assertStringNotContainsString('secret', $gadget, 'private methods are not API'); + } + + public function test_signatures_come_from_the_code_and_prose_from_the_docblock(): void + { + $gadget = $this->generate()['Widgets/Gadget.md']; + + self::assertStringContainsString('public static function assemble(string $name, string|int $count = 3, bool $strict = false): array;', $gadget); + self::assertStringContainsString('public function __construct(array $sizes = [], ?Closure $notify = null);', $gadget); + self::assertStringContainsString('implements [`Part`](Part.md)', $gadget); + self::assertStringNotContainsString('Stringable', $gadget, 'an interface PHP adds by itself is not part of the design'); + self::assertStringContainsString('The second paragraph says why, with `code` in it.', $gadget); + self::assertStringContainsString('| `$count` | `int\|string` | How many, as a number or a word. |', $gadget, 'a docblock type and its wrapped text'); + self::assertStringContainsString('**Returns** `list` — The part names.', $gadget); + self::assertStringContainsString('**Throws** `InvalidArgumentException` — When the name is empty.', $gadget); + self::assertStringContainsString('| `$sizes` (readonly) | `array` | Part sizes by name. |', $gadget, 'a promoted property takes the constructor\'s type and text'); + } + + public function test_constants_enum_cases_interfaces_and_deprecation_are_documented(): void + { + $files = $this->generate(); + + self::assertStringContainsString('| `CAPACITY` | `8` | How many parts fit. |', $files['Widgets/Gadget.md']); + self::assertStringContainsString('| `Fast` | `\'fast\'` |', $files['Widgets/Mode.md']); + self::assertStringContainsString('`enum Codad5\WPToolkit\Widgets\Mode`', $files['Widgets/Mode.md']); + self::assertStringContainsString('`interface Codad5\WPToolkit\Widgets\Part`', $files['Widgets/Part.md']); + self::assertStringContainsString('> **Deprecated.** Use Gadget.', $files['Widgets/Legacy.md']); + self::assertStringContainsString('stability: "deprecated"', $files['Widgets/Legacy.md']); + } + + public function test_constants_that_change_every_release_are_not_printed(): void + { + $gadget = $this->generate(['volatile' => ['Codad5\\WPToolkit\\Widgets\\Gadget::RELEASE']])['Widgets/Gadget.md']; + + self::assertStringContainsString('| `RELEASE` | `(set at release)` |', $gadget); + self::assertStringNotContainsString('rc.9', $gadget, 'so a version bump does not make the reference stale'); + } + + public function test_documented_classes_are_linked_and_adrs_resolve_to_their_files(): void + { + $files = $this->generate(); + + self::assertStringContainsString('| `$gadget` | [`Gadget`](../Gadget.md) | The one to use. |', $files['Widgets/Deep/Nested.md']); + self::assertStringContainsString('**Returns** [`?Gadget`](../Gadget.md)', $files['Widgets/Deep/Nested.md']); + self::assertStringContainsString('public function use(Gadget $gadget): ?Gadget;', $files['Widgets/Deep/Nested.md'], 'class names are short in signatures'); + self::assertStringContainsString('[ADR-0007](../../adr/0007-fixture.md)', $files['Widgets/Gadget.md']); + self::assertStringContainsString('[ADR-0007](../../../adr/0007-fixture.md)', $files['Widgets/Deep/Nested.md'] . '[ADR-0007](../../../adr/0007-fixture.md)', 'one level deeper, one more ../'); + } + + // --- Front matter and order ------------------------------------------------------------------ + + public function test_every_page_has_front_matter_with_the_documented_fields(): void + { + $page = $this->generate()['Widgets/Gadget.md']; + + self::assertStringStartsWith("---\nschema: \"wptoolkit-api/1\"\ntitle: \"Gadget\"\ntype: \"class\"\n", $page); + foreach (['fqcn: "Codad5\\\\WPToolkit\\\\Widgets\\\\Gadget"', 'layer: "Widgets"', 'source: "src/Widgets/Gadget.php"', 'since: "1.2.0"', 'stability: "stable"', 'related: { adr: [7] }'] as $line) { + self::assertStringContainsString($line . "\n", $page); + } + self::assertMatchesRegularExpression('/^order: \d+$/m', $page); + self::assertMatchesRegularExpression('/^summary: "A gadget\. Assembles parts \(ADR-0007\)\."$/m', $page); + self::assertStringContainsString("\n---\n\n# Gadget\n", $page); + } + + public function test_a_class_without_a_since_tag_is_since_1_0_0(): void + { + self::assertStringContainsString('since: "1.0.0"', $this->generate()['Widgets/Zeta.md']); + } + + public function test_pages_form_one_reading_chain_with_unique_increasing_order(): void + { + $files = $this->generate(); + $index = json_decode($files['index.json'], true); + self::assertIsArray($index); + $pages = $index['pages']; + + self::assertSame('wptoolkit-api/1', $index['schema']); + self::assertSame(range(0, count($pages) - 1), array_column($pages, 'order'), 'orders are unique and consecutive'); + self::assertSame('index.md', $pages[0]['path']); + self::assertNull($pages[0]['prev']); + self::assertNull($pages[array_key_last($pages)]['next']); + foreach ($pages as $i => $page) { + self::assertSame($pages[$i - 1]['path'] ?? null, $page['prev'], $page['path'] . ': prev'); + self::assertSame($pages[$i + 1]['path'] ?? null, $page['next'], $page['path'] . ': next'); + self::assertArrayHasKey($page['path'], $files, 'every indexed page exists'); + } + } + + public function test_the_configured_classes_come_first_and_the_rest_alphabetically(): void + { + $titles = array_column(json_decode($this->generate()['index.json'], true)['pages'], 'title'); + + self::assertSame(['API reference', 'Widgets', 'Gadget', 'Nested', 'Legacy', 'Mode', 'Part', 'Zeta'], $titles); + } + + public function test_front_matter_links_are_relative_to_the_page_and_the_footer_repeats_them(): void + { + $nested = $this->generate()['Widgets/Deep/Nested.md']; + + self::assertStringContainsString('prev: { title: "Gadget", path: "../Gadget.md" }', $nested); + self::assertStringContainsString('up: { title: "Widgets", path: "../index.md" }', $nested); + self::assertStringContainsString('← [Previous: Gadget](../Gadget.md) · [Up: Widgets](../index.md) · [Next: Legacy](../Legacy.md) →', $nested); + } + + public function test_layer_and_top_pages_list_their_children(): void + { + $files = $this->generate(); + + self::assertStringContainsString('| [Gadget](Gadget.md) | class | A gadget. Assembles parts (ADR-0007). |', $files['Widgets/index.md']); + self::assertStringContainsString('type: "namespace"', $files['Widgets/index.md']); + self::assertStringContainsString('| [Widgets](Widgets/index.md) | namespace |', $files['index.md']); + } + + // --- Determinism and drift -------------------------------------------------------------------- + + public function test_output_is_deterministic_with_lf_line_endings(): void + { + $first = $this->generate(); + $second = $this->generate(); + + self::assertSame($first, $second); + foreach ($first as $path => $contents) { + self::assertStringNotContainsString("\r", $contents, $path); + self::assertStringEndsWith("\n", $contents, $path); + } + } + + public function test_a_reference_on_disk_that_matches_the_source_has_no_drift(): void + { + $generator = $this->generator(); + $generator->write($generator->build()); + + self::assertSame([], $generator->drift($generator->build())); + } + + public function test_drift_names_changed_missing_and_stale_files(): void + { + $generator = $this->generator(); + $generator->write($generator->build()); + file_put_contents($this->output . '/Widgets/Zeta.md', 'edited by hand'); + unlink($this->output . '/Widgets/Part.md'); + file_put_contents($this->output . '/Widgets/Gone.md', 'a class that was deleted'); + + $problems = $generator->drift($generator->build()); + + self::assertContains('out of date: out/Widgets/Zeta.md', $problems); + self::assertContains('missing: out/Widgets/Part.md', $problems); + self::assertContains('stale (no longer generated): out/Widgets/Gone.md', $problems); + } + + public function test_windows_line_endings_on_disk_do_not_count_as_drift(): void + { + $generator = $this->generator(); + $generator->write($generator->build()); + $file = $this->output . '/Widgets/Zeta.md'; + file_put_contents($file, str_replace("\n", "\r\n", (string) file_get_contents($file))); + + self::assertSame([], $generator->drift($generator->build()), 'a checkout with autocrlf must not fail the check'); + } + + public function test_writing_removes_files_for_classes_that_no_longer_exist(): void + { + $generator = $this->generator(); + mkdir($this->output . '/Widgets', 0777, true); + file_put_contents($this->output . '/Widgets/Gone.md', 'stale'); + + $generator->write($generator->build()); + + self::assertFileDoesNotExist($this->output . '/Widgets/Gone.md'); + self::assertFileExists($this->output . '/Widgets/Gadget.md'); + } + + /** + * @param array $override + * @return array + */ + private function generate(array $override = []): array + { + return $this->generator($override)->build(); + } + + /** + * @param array $override + */ + private function generator(array $override = []): Generator + { + return new Generator($this->root, array_replace([ + 'output' => 'out', + 'layers' => ['Widgets'], + 'first' => ['Widgets' => ['Gadget', 'Nested']], + 'summaries' => ['Widgets' => 'Things built from parts.'], + 'volatile' => [], + ], $override)); + } + + private function copy(string $from, string $to): void + { + mkdir($to, 0777, true); + $items = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($from, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST); + foreach ($items as $item) { + $target = $to . substr(str_replace('\\', '/', $item->getPathname()), strlen(str_replace('\\', '/', $from))); + $item->isDir() ? mkdir($target, 0777, true) : copy($item->getPathname(), $target); + } + } +} diff --git a/tests/docs/api-reference.test.mjs b/tests/docs/api-reference.test.mjs new file mode 100644 index 0000000..47506ee --- /dev/null +++ b/tests/docs/api-reference.test.mjs @@ -0,0 +1,127 @@ +// The API reference (docs/api, ADR-0028) read the way documentation tooling reads it: with gray-matter. +// PHP generates the files; this proves that real front-matter tools can follow them — every page +// parses, carries the documented fields, and previous / next / up lead where they say. +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import matter from 'gray-matter'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../docs/api'); +const SCHEMA = 'wptoolkit-api/1'; +const TYPES = ['class', 'interface', 'enum', 'trait', 'namespace', 'index']; + +function markdownFiles(directory) { + return readdirSync(directory).flatMap((name) => { + const path = join(directory, name); + return statSync(path).isDirectory() ? markdownFiles(path) : path.endsWith('.md') ? [path] : []; + }); +} + +const pages = markdownFiles(root).map((file) => { + const parsed = matter(readFileSync(file, 'utf8')); + return { file, path: relative(root, file).split(sep).join('/'), data: parsed.data, body: parsed.content }; +}); +const byPath = new Map(pages.map((page) => [page.path, page])); +const ordered = [...pages].sort((a, b) => a.data.order - b.data.order); + +test('there are pages to read', () => { + assert.ok(pages.length > 10, `only ${pages.length} pages`); + assert.ok(byPath.has('index.md')); +}); + +test('every page declares the schema and the fields its type requires', () => { + for (const { path, data } of pages) { + assert.equal(data.schema, SCHEMA, `${path}: schema`); + assert.ok(TYPES.includes(data.type), `${path}: type "${data.type}"`); + assert.equal(typeof data.title, 'string', `${path}: title`); + assert.ok(data.title.length > 0, `${path}: title is empty`); + assert.equal(typeof data.summary, 'string', `${path}: summary`); + assert.ok(Number.isInteger(data.order), `${path}: order must be an integer (it parsed as ${typeof data.order})`); + + if (!['namespace', 'index'].includes(data.type)) { + for (const field of ['fqcn', 'namespace', 'layer', 'source', 'since', 'stability']) { + assert.equal(typeof data[field], 'string', `${path}: ${field}`); + assert.ok(data[field].length > 0, `${path}: ${field} is empty`); + } + assert.ok(['stable', 'deprecated'].includes(data.stability), `${path}: stability`); + assert.match(data.since, /^\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?$/, `${path}: since is a version, and a string`); + assert.ok(data.fqcn.startsWith(`${data.namespace}\\`), `${path}: fqcn belongs to its namespace`); + assert.ok(existsSync(resolve(root, '../..', data.source)), `${path}: source ${data.source} exists`); + } + if (data.related !== undefined) { + assert.ok(Array.isArray(data.related.adr), `${path}: related.adr is a list`); + assert.ok(data.related.adr.every(Number.isInteger), `${path}: related.adr holds numbers`); + } + } +}); + +test('orders are unique and run from 0 with no gaps', () => { + assert.deepEqual(ordered.map((page) => page.data.order), ordered.map((_, i) => i)); +}); + +test('previous, next and up are links that work from the page they are on', () => { + ordered.forEach((page, i) => { + const here = dirname(page.file); + for (const [name, expected] of [['prev', ordered[i - 1]], ['next', ordered[i + 1]]]) { + const link = page.data[name]; + if (expected === undefined) { + assert.equal(link, undefined, `${page.path}: has no ${name}`); + continue; + } + assert.ok(link, `${page.path}: missing ${name}`); + assert.equal(link.title, expected.data.title, `${page.path}: ${name} title`); + assert.equal(resolve(here, link.path), expected.file, `${page.path}: ${name} path leads to ${expected.path}`); + assert.ok(page.body.includes(`](${link.path})`), `${page.path}: the footer links ${name} too`); + } + if (page.data.up !== undefined) { + const up = byPath.get(relative(root, resolve(here, page.data.up.path)).split(sep).join('/')); + assert.ok(up, `${page.path}: up leads to a page`); + assert.equal(up.data.title, page.data.up.title, `${page.path}: up title`); + } + }); +}); + +test('every relative link in a page body points at a file that exists', () => { + for (const page of pages) { + for (const [, target] of page.body.matchAll(/\]\((?!https?:|#)([^)#\s]+)(?:#[^)]*)?\)/g)) { + assert.ok(existsSync(resolve(dirname(page.file), target)), `${page.path}: broken link ${target}`); + } + } +}); + +test('index.json agrees with the front matter and joins up', () => { + const index = JSON.parse(readFileSync(join(root, 'index.json'), 'utf8')); + assert.equal(index.schema, SCHEMA); + assert.equal(index.pages.length, pages.length, 'every page is indexed'); + + index.pages.forEach((entry, i) => { + const page = byPath.get(entry.path); + assert.ok(page, `${entry.path} exists`); + assert.equal(entry.order, i); + assert.equal(entry.order, page.data.order, `${entry.path}: order`); + assert.equal(entry.title, page.data.title, `${entry.path}: title`); + assert.equal(entry.summary, page.data.summary, `${entry.path}: summary`); + assert.equal(entry.fqcn ?? undefined, page.data.fqcn, `${entry.path}: fqcn`); + assert.equal(entry.prev, index.pages[i - 1]?.path ?? null, `${entry.path}: prev joins`); + assert.equal(entry.next, index.pages[i + 1]?.path ?? null, `${entry.path}: next joins`); + if (entry.up !== null) { + assert.ok(byPath.has(entry.up), `${entry.path}: up ${entry.up} is a page`); + } + }); +}); + +test('class names are unique, and every documented class is reachable by following next', () => { + const names = pages.filter((page) => page.data.fqcn).map((page) => page.data.fqcn); + assert.equal(new Set(names).size, names.length, 'duplicate fqcn'); + + const seen = new Set(); + let page = byPath.get('index.md'); + while (page) { + assert.ok(!seen.has(page.path), `loop at ${page.path}`); + seen.add(page.path); + page = page.data.next ? byPath.get(relative(root, resolve(dirname(page.file), page.data.next.path)).split(sep).join('/')) : undefined; + } + assert.equal(seen.size, pages.length, 'following next from the index visits every page'); +}); diff --git a/tools/apidocs/config.json b/tools/apidocs/config.json new file mode 100644 index 0000000..8ba6c65 --- /dev/null +++ b/tools/apidocs/config.json @@ -0,0 +1,13 @@ +{ + "output": "docs/api", + "layers": ["Foundation", "Http"], + "first": { + "Foundation": ["Application", "ServiceProvider", "Identity", "Config", "HookRegistrar", "Container"], + "Http": ["Router", "Route", "Request", "Response", "HttpError", "Dispatcher"] + }, + "volatile": ["Codad5\\WPToolkit\\Foundation\\Application::VERSION"], + "summaries": { + "Foundation": "The kernel: one Application per plugin, its container and providers, the hooks it adds, and the prefixed names (Identity) everything else is built from.", + "Http": "One pipeline for REST, admin-ajax and public pages: the router and its fluent routes, requests and responses, and the dispatcher that enforces access, validation and rate limits." + } +} diff --git a/tools/apidocs/src/ClassModel.php b/tools/apidocs/src/ClassModel.php new file mode 100644 index 0000000..3f0d4c9 --- /dev/null +++ b/tools/apidocs/src/ClassModel.php @@ -0,0 +1,412 @@ +, + * returns: array{type: string, text: string}|null, + * throws: list, + * deprecated: string|null + * } + * @phpstan-type ClassDoc array{ + * kind: string, + * declaration: string, + * fqcn: string, + * short: string, + * namespace: string, + * extends: string|null, + * implements: list, + * summary: string, + * prose: string, + * since: string, + * deprecated: string|null, + * adrs: list, + * constants: list, + * cases: list, + * properties: list, + * methods: list + * } + */ +final class ClassModel +{ + /** A class documented before `@since` tags existed shipped in the first 1.0 candidate. */ + public const DEFAULT_SINCE = '1.0.0'; + + /** Interfaces PHP adds by itself (a `__toString()` makes a class Stringable; every enum is a UnitEnum): noise, not design. */ + private const IMPLICIT = ['Stringable', 'UnitEnum', 'BackedEnum']; + + /** + * @param ReflectionClass $class + * @param list $volatile `Class::CONSTANT` names whose value changes with every release; the value is not printed. + * @return ClassDoc|null Null for a class marked `@internal`: it is wiring, not API. + */ + public static function of(ReflectionClass $class, array $volatile = []): ?array + { + $doc = DocBlock::parse($class->getDocComment()); + if ($doc->has('internal') || $class->isAnonymous()) { + return null; + } + + $parent = $class->getParentClass(); + $deprecated = $doc->first('deprecated'); + $since = $doc->first('since'); + + return [ + 'kind' => self::kind($class), + 'declaration' => self::declaration($class), + 'fqcn' => $class->getName(), + 'short' => $class->getShortName(), + 'namespace' => $class->getNamespaceName(), + 'extends' => $parent === false ? null : $parent->getName(), + 'implements' => array_values(array_filter($class->getInterfaceNames(), static fn (string $name): bool => !in_array($name, self::IMPLICIT, true))), + 'summary' => $doc->summary, + 'prose' => $doc->prose(), + 'since' => $since !== null && $since['text'] !== '' ? $since['text'] : self::DEFAULT_SINCE, + 'deprecated' => $deprecated === null ? null : $deprecated['text'], + 'adrs' => self::adrs($class), + 'constants' => self::constants($class, $volatile), + 'cases' => self::cases($class), + 'properties' => self::properties($class), + 'methods' => self::methods($class), + ]; + } + + /** + * @param ReflectionClass $class + */ + private static function kind(ReflectionClass $class): string + { + return match (true) { + $class->isEnum() => 'enum', + $class->isInterface() => 'interface', + $class->isTrait() => 'trait', + default => 'class', + }; + } + + /** + * @param ReflectionClass $class + */ + private static function declaration(ReflectionClass $class): string + { + $kind = self::kind($class); + if ($kind !== 'class') { + return $kind; + } + + // ReflectionClass::isReadOnly() arrived in PHP 8.2; the library's floor is 8.1. + $readonly = method_exists($class, 'isReadOnly') && $class->isReadOnly() ? 'readonly ' : ''; + + return ($class->isFinal() ? 'final ' : ($class->isAbstract() ? 'abstract ' : '')) . $readonly . 'class'; + } + + /** + * ADRs the class and its methods cite, so a page can link the decisions behind it. + * + * @param ReflectionClass $class + * @return list + */ + private static function adrs(ReflectionClass $class): array + { + $text = (string) $class->getDocComment(); + foreach ($class->getMethods() as $method) { + if ($method->getDeclaringClass()->getName() === $class->getName()) { + $text .= (string) $method->getDocComment(); + } + } + + preg_match_all('/ADR-(\d{4})|adr\/(\d{4})-/', $text, $matches); + $numbers = array_map('intval', array_filter(array_merge($matches[1], $matches[2]), static fn (string $n): bool => $n !== '')); + $numbers = array_values(array_unique($numbers)); + sort($numbers); + + return $numbers; + } + + /** + * @param ReflectionClass $class + * @param list $volatile + * @return list + */ + private static function constants(ReflectionClass $class, array $volatile): array + { + $constants = []; + foreach ($class->getReflectionConstants(ReflectionClassConstant::IS_PUBLIC) as $constant) { + if ($constant->getDeclaringClass()->getName() !== $class->getName() || $constant->isEnumCase()) { + continue; + } + $doc = DocBlock::parse($constant->getDocComment()); + if ($doc->has('internal')) { + continue; + } + $isVolatile = in_array($class->getName() . '::' . $constant->getName(), $volatile, true); + $constants[] = [ + 'name' => $constant->getName(), + 'value' => $isVolatile ? '(set at release)' : self::export($constant->getValue()), + 'summary' => $doc->summary, + ]; + } + + return $constants; + } + + /** + * @param ReflectionClass $class + * @return list + */ + private static function cases(ReflectionClass $class): array + { + $name = $class->getName(); + if (!$class->isEnum() || !enum_exists($name)) { + return []; + } + + $cases = []; + foreach ((new ReflectionEnum($name))->getCases() as $case) { + $value = $case instanceof ReflectionEnumBackedCase ? $case->getBackingValue() : null; + $cases[] = ['name' => $case->getName(), 'value' => $value === null ? '' : self::export($value)]; + } + + return $cases; + } + + /** + * Public properties the class declares itself; a promoted constructor property takes its text + * from the constructor's `@param`. + * + * @param ReflectionClass $class + * @return list + */ + private static function properties(ReflectionClass $class): array + { + $promoted = []; + $constructor = $class->getConstructor(); + if ($constructor !== null) { + foreach (DocBlock::parse($constructor->getDocComment())->all('param') as $tag) { + $promoted[ltrim($tag['variable'], '$')] = $tag; + } + } + + $properties = []; + foreach ($class->getProperties(ReflectionProperty::IS_PUBLIC) as $property) { + if ($property->getDeclaringClass()->getName() !== $class->getName() || $property->isStatic()) { + continue; + } + $doc = DocBlock::parse($property->getDocComment()); + $var = $doc->first('var'); + $param = $promoted[$property->getName()] ?? null; + $documented = $var ?? $param; + $properties[] = [ + 'name' => $property->getName(), + 'type' => $documented !== null && $documented['type'] !== '' ? ltrim($documented['type'], '\\') : self::type($property->getType()), + 'readonly' => $property->isReadOnly(), + 'summary' => $doc->summary !== '' ? $doc->summary : ($param['text'] ?? ''), + ]; + } + + return $properties; + } + + /** + * @param ReflectionClass $class + * @return list + */ + private static function methods(ReflectionClass $class): array + { + $methods = []; + foreach ($class->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + if ($method->getDeclaringClass()->getName() !== $class->getName()) { + continue; + } + $doc = DocBlock::parse($method->getDocComment()); + if ($doc->has('internal') || str_starts_with($method->getName(), '__') && $method->getName() !== '__construct') { + continue; + } + + $returns = $doc->first('return'); + $deprecated = $doc->first('deprecated'); + $methods[] = [ + 'name' => $method->getName(), + 'static' => $method->isStatic(), + 'signature' => self::signature($method), + 'summary' => $doc->summary, + 'prose' => $doc->prose(), + 'params' => self::params($method, $doc), + 'returns' => $method->getName() === '__construct' + ? null + : self::returns($method, $returns), + 'throws' => array_map( + static fn (array $t): array => ['type' => ltrim($t['type'], '\\'), 'text' => $t['text']], + $doc->all('throws') + ), + 'deprecated' => $deprecated === null ? null : $deprecated['text'], + ]; + } + + return $methods; + } + + /** + * @param array{name: string, type: string, variable: string, text: string}|null $tag + * @return array{type: string, text: string}|null + */ + private static function returns(ReflectionMethod $method, ?array $tag): ?array + { + $native = self::type($method->getReturnType()); + $type = $tag !== null && $tag['type'] !== '' ? ltrim($tag['type'], '\\') : $native; + + return $type === '' || $type === 'void' ? null : ['type' => $type, 'text' => $tag['text'] ?? '']; + } + + /** + * @return list + */ + private static function params(ReflectionFunctionAbstract $method, DocBlock $doc): array + { + $tags = []; + foreach ($doc->all('param') as $tag) { + $tags[ltrim($tag['variable'], '$')] = $tag; + } + + $params = []; + foreach ($method->getParameters() as $parameter) { + $tag = $tags[$parameter->getName()] ?? null; + $params[] = [ + 'name' => '$' . $parameter->getName(), + 'type' => $tag !== null && $tag['type'] !== '' ? ltrim($tag['type'], '\\') : (self::type($parameter->getType()) ?: 'mixed'), + 'text' => $tag['text'] ?? '', + 'signature' => self::parameter($parameter), + ]; + } + + return $params; + } + + private static function signature(ReflectionMethod $method): string + { + $abstract = $method->isAbstract() && !$method->getDeclaringClass()->isInterface(); + $modifiers = 'public' . ($method->isStatic() ? ' static' : '') . ($abstract ? ' abstract' : ''); + $return = self::type($method->getReturnType()); + + return sprintf( + '%s function %s(%s)%s', + $modifiers, + $method->getName(), + implode(', ', array_map(self::parameter(...), $method->getParameters())), + $return === '' ? '' : ': ' . $return + ); + } + + private static function parameter(ReflectionParameter $parameter): string + { + $type = self::type($parameter->getType()); + $text = ($type === '' ? '' : $type . ' ') + . ($parameter->isPassedByReference() ? '&' : '') + . ($parameter->isVariadic() ? '...' : '') + . '$' . $parameter->getName(); + + if ($parameter->isDefaultValueAvailable()) { + $default = $parameter->isDefaultValueConstant() + ? (string) $parameter->getDefaultValueConstantName() + : self::export($parameter->getDefaultValue()); + $text .= ' = ' . $default; + } + + return $text; + } + + /** + * A native type as written in code, with class names shortened: a page already says which + * namespace it is in, and `Container` reads better than `Codad5\\WPToolkit\\Foundation\\Container`. + */ + private static function type(?ReflectionType $type): string + { + if ($type === null) { + return ''; + } + if ($type instanceof ReflectionNamedType) { + $name = $type->isBuiltin() ? $type->getName() : self::short($type->getName()); + + return ($type->allowsNull() && $type->getName() !== 'mixed' && $type->getName() !== 'null' ? '?' : '') . $name; + } + if ($type instanceof ReflectionUnionType || $type instanceof ReflectionIntersectionType) { + $glue = $type instanceof ReflectionUnionType ? '|' : '&'; + + return implode($glue, array_map( + static fn (ReflectionType $t): string => $t instanceof ReflectionNamedType && !$t->isBuiltin() ? self::short($t->getName()) : (string) $t, + $type->getTypes() + )); + } + + return (string) $type; + } + + private static function short(string $name): string + { + $position = strrpos($name, '\\'); + + return $position === false ? $name : substr($name, $position + 1); + } + + /** + * A value as PHP source, short and stable: `'text'`, `[]`, `['a' => 1]`, `null`. + */ + private static function export(mixed $value): string + { + return match (true) { + $value === null => 'null', + is_bool($value) => $value ? 'true' : 'false', + is_string($value) => "'" . str_replace(["\\", "'", "\0"], ["\\\\", "\\'", '\0'], $value) . "'", + is_array($value) => self::exportArray($value), + is_object($value) => $value::class . ' instance', + default => var_export($value, true), + }; + } + + /** + * @param array $value + */ + private static function exportArray(array $value): string + { + if ($value === []) { + return '[]'; + } + + $isList = array_is_list($value); + $parts = []; + foreach ($value as $key => $item) { + $parts[] = ($isList ? '' : self::export($key) . ' => ') . self::export($item); + } + + return '[' . implode(', ', $parts) . ']'; + } +} diff --git a/tools/apidocs/src/DocBlock.php b/tools/apidocs/src/DocBlock.php new file mode 100644 index 0000000..ced15ce --- /dev/null +++ b/tools/apidocs/src/DocBlock.php @@ -0,0 +1,160 @@ +`, `Closure(string): void`, `array{a: int}`), which contain spaces. + */ +final class DocBlock +{ + /** + * @param list $tags + */ + private function __construct( + public readonly string $summary, + public readonly string $description, + public readonly array $tags + ) { + } + + public static function parse(string|false $raw): self + { + if ($raw === false || trim($raw) === '') { + return new self('', '', []); + } + + $description = []; + $tags = []; + foreach (self::lines($raw) as $line) { + if (preg_match('/^@([A-Za-z][A-Za-z0-9\-]*)\s*(.*)$/', ltrim($line), $match) === 1) { + $tags[] = self::tag($match[1], $match[2]); + continue; + } + if ($tags !== []) { + $last = array_key_last($tags); + $tags[$last]['text'] = trim($tags[$last]['text'] . ' ' . trim($line)); + continue; + } + $description[] = rtrim($line); + } + + $body = trim(implode("\n", $description)); + $paragraph = preg_split('/\n\s*\n/', $body, 2); + $summary = trim((string) preg_replace('/\s+/', ' ', is_array($paragraph) ? $paragraph[0] : $body)); + + return new self($summary, $body, $tags); + } + + public function has(string $tag): bool + { + return $this->first($tag) !== null; + } + + /** + * @return array{name: string, type: string, variable: string, text: string}|null + */ + public function first(string $tag): ?array + { + foreach ($this->tags as $candidate) { + if ($candidate['name'] === $tag) { + return $candidate; + } + } + + return null; + } + + /** + * @return list + */ + public function all(string $tag): array + { + return array_values(array_filter($this->tags, static fn (array $t): bool => $t['name'] === $tag)); + } + + /** + * The prose without the summary's own line wrapping, for the page's introduction. + */ + public function prose(): string + { + return $this->description; + } + + /** + * @return list + */ + private static function lines(string $raw): array + { + $raw = (string) preg_replace('#^\s*/\*\*#', '', $raw); + $raw = (string) preg_replace('#\*/\s*$#', '', $raw); + $lines = []; + foreach (preg_split('/\R/', $raw) ?: [] as $line) { + // One space after the asterisk is the gutter; anything beyond it is indentation (code). + $lines[] = (string) preg_replace('/^\s*\* ?/', '', $line); + } + + return $lines; + } + + /** + * @return array{name: string, type: string, variable: string, text: string} + */ + private static function tag(string $name, string $rest): array + { + $type = ''; + $variable = ''; + + if (in_array($name, ['param', 'return', 'throws', 'var', 'property', 'phpstan-return', 'phpstan-param'], true)) { + [$type, $rest] = self::splitType($rest); + $named = in_array($name, ['param', 'var', 'property', 'phpstan-param'], true); + if ($named && preg_match('/^(\.\.\.)?(&)?(\$[A-Za-z_][A-Za-z0-9_]*)\s*(.*)$/s', $rest, $m) === 1) { + $variable = $m[3]; + $rest = $m[4]; + } + } + + return ['name' => $name, 'type' => $type, 'variable' => $variable, 'text' => trim($rest)]; + } + + /** + * Read one type off the front of a tag. Whitespace inside `<>`, `()`, `{}` and `[]` belongs to the + * type, and so does the space after a callable's `:` (`Closure(string): void`). + * + * @return array{0: string, 1: string} The type, and what follows it. + */ + private static function splitType(string $text): array + { + $text = ltrim($text); + $depth = 0; + $length = strlen($text); + for ($i = 0; $i < $length; $i++) { + $char = $text[$i]; + if (str_contains('<({[', $char)) { + $depth++; + } elseif (str_contains('>)}]', $char)) { + $depth--; + } elseif (ctype_space($char) && $depth <= 0) { + $before = rtrim(substr($text, 0, $i)); + if (str_ends_with($before, ':') || str_ends_with($before, '|')) { + continue; + } + $after = ltrim(substr($text, $i)); + if (str_starts_with($after, '|')) { + continue; + } + + return [$before, $after]; + } + } + + return [trim($text), '']; + } +} diff --git a/tools/apidocs/src/Generator.php b/tools/apidocs/src/Generator.php new file mode 100644 index 0000000..1144022 --- /dev/null +++ b/tools/apidocs/src/Generator.php @@ -0,0 +1,377 @@ +, + * first: array>, + * summaries: array, + * volatile: list + * } + */ +final class Generator +{ + private const ROOT_NAMESPACE = 'Codad5\\WPToolkit\\'; + + /** + * @param Config $config + */ + public function __construct(private readonly string $root, private readonly array $config) + { + } + + /** + * @param string $root The repository root. + */ + public static function fromConfigFile(string $root): self + { + $json = json_decode((string) file_get_contents($root . '/tools/apidocs/config.json'), true); + if (!is_array($json) || !isset($json['layers'], $json['output'])) { + throw new \RuntimeException('tools/apidocs/config.json is missing or invalid.'); + } + + return new self($root, [ + 'output' => (string) $json['output'], + 'layers' => array_values(array_map('strval', (array) $json['layers'])), + 'first' => is_array($json['first'] ?? null) ? $json['first'] : [], + 'summaries' => is_array($json['summaries'] ?? null) ? $json['summaries'] : [], + 'volatile' => array_values(array_map('strval', (array) ($json['volatile'] ?? []))), + ]); + } + + /** + * Every file the reference consists of: path (relative to the output directory) => contents. + * + * @return array + */ + public function build(): array + { + $pages = $this->pages(); + $paths = []; + foreach ($pages as $page) { + if ($page['fqcn'] !== '') { + $paths[$page['fqcn']] = $page['path']; + } + } + $shortNames = $this->uniqueShortNames($paths); + + $markdown = new Markdown( + fn (string $name, string $from): ?string => $this->link($paths, $shortNames, $name, $from), + fn (int $number, string $from): ?string => $this->adrPath($number, $from) + ); + + $files = []; + $index = []; + foreach ($pages as $position => $page) { + $files[$page['path']] = $markdown->render($page); + $up = match ($page['type']) { + 'class' => $page['layer'] . '/index.md', + 'namespace' => $pages[0]['path'], + default => null, + }; + $index[] = [ + 'path' => $page['path'], + 'title' => $page['title'], + 'type' => $page['type'] === 'class' ? (string) ($page['doc']['kind'] ?? 'class') : $page['type'], + 'fqcn' => $page['fqcn'] === '' ? null : $page['fqcn'], + 'layer' => $page['layer'], + 'order' => $page['order'], + 'since' => $page['type'] === 'class' ? $page['since'] : null, + 'summary' => $page['summary'], + 'prev' => $pages[$position - 1]['path'] ?? null, + 'next' => $pages[$position + 1]['path'] ?? null, + 'up' => $up, + ]; + } + $files['index.json'] = json_encode( + ['schema' => Markdown::SCHEMA, 'pages' => $index], + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE + ) . "\n"; + + ksort($files, SORT_STRING); + + return $files; + } + + /** + * What differs between the reference on disk and what the source says it should be. + * + * @param array $files + * @return list One line per problem; empty when the reference is current. + */ + public function drift(array $files): array + { + $problems = []; + $output = $this->outputDirectory(); + + foreach ($files as $path => $contents) { + $file = $output . '/' . $path; + if (!is_file($file)) { + $problems[] = 'missing: ' . $this->config['output'] . '/' . $path; + } elseif (str_replace("\r\n", "\n", (string) file_get_contents($file)) !== $contents) { + $problems[] = 'out of date: ' . $this->config['output'] . '/' . $path; + } + } + foreach ($this->existing() as $path) { + if (!isset($files[$path])) { + $problems[] = 'stale (no longer generated): ' . $this->config['output'] . '/' . $path; + } + } + + return $problems; + } + + /** + * @param array $files + */ + public function write(array $files): void + { + $output = $this->outputDirectory(); + foreach ($this->existing() as $path) { + if (!isset($files[$path])) { + unlink($output . '/' . $path); + } + } + foreach ($files as $path => $contents) { + $file = $output . '/' . $path; + if (!is_dir(dirname($file)) && !mkdir(dirname($file), 0777, true) && !is_dir(dirname($file))) { + throw new \RuntimeException('Cannot create ' . dirname($file)); + } + file_put_contents($file, $contents); + } + } + + /** + * All pages in reading order, with their neighbours. + * + * @return list + */ + private function pages(): array + { + $top = $this->page('index', 'API reference', 'index.md', '', ''); + $top['summary'] = 'Every public class of WPToolkit, in the order to read them.'; + $top['intro'] = 'Generated from the source by `composer docs`; do not edit these files. Each page lists a class\'s public API ' + . 'with the reasoning from its docblocks. Pages carry machine-readable front matter (schema `' . Markdown::SCHEMA + . '`, [ADR-0028](../adr/0028-the-api-reference-is-generated-from-the-source.md)) and `index.json` lists them all in reading order.'; + + $chain = [$top]; + foreach ($this->config['layers'] as $layer) { + $classes = $this->classes($layer); + $index = $this->page('namespace', $layer, $layer . '/index.md', $layer, self::ROOT_NAMESPACE . $layer); + $index['summary'] = $this->config['summaries'][$layer] ?? ''; + $index['intro'] = $index['summary']; + $index['children'] = array_map(static fn (array $page): array => [ + 'title' => $page['title'], + 'path' => substr($page['path'], strlen($layer) + 1), + 'kind' => (string) ($page['doc']['kind'] ?? 'class'), + 'summary' => $page['summary'], + ], $classes); + $chain[] = $index; + array_push($chain, ...$classes); + } + + $top['children'] = array_map(fn (array $layerIndex): array => [ + 'title' => $layerIndex['title'], + 'path' => $layerIndex['path'], + 'kind' => 'namespace', + 'summary' => $layerIndex['summary'], + ], array_values(array_filter($chain, static fn (array $p): bool => $p['type'] === 'namespace'))); + $chain[0] = $top; + + return $this->linkChain($chain); + } + + /** + * @param list $chain + * @return list + */ + private function linkChain(array $chain): array + { + $topPath = $chain[0]['path']; + foreach ($chain as $i => $page) { + $directory = dirname($page['path']) === '.' ? '' : dirname($page['path']); + $chain[$i]['order'] = $i; + $chain[$i]['prev'] = $i > 0 ? $this->reference($chain[$i - 1], $directory) : null; + $chain[$i]['next'] = $i < count($chain) - 1 ? $this->reference($chain[$i + 1], $directory) : null; + if ($page['type'] === 'class') { + $layerIndex = $page['layer'] . '/index.md'; + $title = $page['layer']; + $chain[$i]['up'] = ['title' => $title, 'path' => self::relative($directory, $layerIndex)]; + } elseif ($page['type'] === 'namespace') { + $chain[$i]['up'] = ['title' => $chain[0]['title'], 'path' => self::relative($directory, $topPath)]; + } + } + + return $chain; + } + + /** + * @param Page $page + * @return array{title: string, path: string} + */ + private function reference(array $page, string $fromDirectory): array + { + return ['title' => $page['title'], 'path' => self::relative($fromDirectory, $page['path'])]; + } + + /** + * @return list + */ + private function classes(string $layer): array + { + $found = []; + $directory = $this->root . '/src/' . $layer; + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS)); + foreach ($files as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + $relative = substr(str_replace('\\', '/', $file->getPathname()), strlen(str_replace('\\', '/', $this->root . '/src/'))); + $fqcn = self::ROOT_NAMESPACE . str_replace('/', '\\', substr($relative, 0, -4)); + if (!class_exists($fqcn) && !interface_exists($fqcn) && !trait_exists($fqcn) && !enum_exists($fqcn)) { + continue; + } + + /** @var class-string $fqcn */ + $doc = ClassModel::of(new ReflectionClass($fqcn), $this->config['volatile']); + if ($doc === null) { + continue; + } + + $page = $this->page('class', $doc['short'], $layer . '/' . substr($relative, strlen($layer) + 1, -4) . '.md', $layer, $fqcn); + $page['doc'] = $doc; + $page['namespace'] = $doc['namespace']; + $page['source'] = 'src/' . $relative; + $page['since'] = $doc['since']; + $page['stability'] = $doc['deprecated'] === null ? 'stable' : 'deprecated'; + $page['summary'] = $doc['summary']; + $page['adrs'] = $doc['adrs']; + $found[] = $page; + } + + $first = $this->config['first'][$layer] ?? []; + usort($found, static function (array $a, array $b) use ($first): int { + $rank = static fn (array $page): int => ($i = array_search($page['title'], $first, true)) === false ? PHP_INT_MAX : (int) $i; + $byRank = $rank($a) <=> $rank($b); + if ($byRank !== 0) { + return $byRank; + } + return strcmp($a['fqcn'], $b['fqcn']); + }); + + return $found; + } + + /** + * @return Page + */ + private function page(string $type, string $title, string $path, string $layer, string $fqcn): array + { + return [ + 'type' => $type, 'path' => $path, 'title' => $title, 'order' => 0, 'layer' => $layer, + 'fqcn' => $type === 'class' ? $fqcn : '', 'namespace' => $type === 'namespace' ? $fqcn : '', 'source' => '', + 'since' => '', 'stability' => 'stable', 'summary' => '', 'adrs' => [], 'prev' => null, 'next' => null, 'up' => null, + 'doc' => null, 'children' => [], 'intro' => '', + ]; + } + + /** + * @param array $paths fqcn => page path + * @return array short class name => fqcn, for names that are unambiguous + */ + private function uniqueShortNames(array $paths): array + { + $count = []; + $map = []; + foreach (array_keys($paths) as $fqcn) { + $short = substr($fqcn, (int) strrpos($fqcn, '\\') + 1); + $count[$short] = ($count[$short] ?? 0) + 1; + $map[$short] = $fqcn; + } + + return array_filter($map, static fn (string $short): bool => $count[substr($short, (int) strrpos($short, '\\') + 1)] === 1); + } + + /** + * @param array $paths + * @param array $shortNames + */ + private function link(array $paths, array $shortNames, string $name, string $from): ?string + { + $name = ltrim($name, '\\'); + $fqcn = isset($paths[$name]) ? $name : ($shortNames[$name] ?? null); + + return $fqcn !== null && isset($paths[$fqcn]) ? self::relative($from, $paths[$fqcn]) : null; + } + + private function adrPath(int $number, string $from): ?string + { + $matches = glob($this->root . '/docs/adr/' . sprintf('%04d', $number) . '-*.md') ?: []; + if ($matches === []) { + return null; + } + + // From docs/api/{from}/ to docs/adr/{file}: out of {from}, out of api. + $depth = $from === '' ? 0 : count(explode('/', $from)); + + return str_repeat('../', $depth + 1) . 'adr/' . basename($matches[0]); + } + + /** + * A path from one output directory to a page, using forward slashes: `../Http/Router.md`. + */ + private static function relative(string $fromDirectory, string $to): string + { + $from = $fromDirectory === '' ? [] : explode('/', $fromDirectory); + $target = explode('/', $to); + while ($from !== [] && count($target) > 1 && $from[0] === $target[0]) { + array_shift($from); + array_shift($target); + } + + return str_repeat('../', count($from)) . implode('/', $target); + } + + private function outputDirectory(): string + { + return $this->root . '/' . $this->config['output']; + } + + /** + * @return list Files already in the output directory, relative to it. + */ + private function existing(): array + { + $output = $this->outputDirectory(); + if (!is_dir($output)) { + return []; + } + + $found = []; + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($output, FilesystemIterator::SKIP_DOTS)); + foreach ($files as $file) { + $found[] = substr(str_replace('\\', '/', $file->getPathname()), strlen(str_replace('\\', '/', $output)) + 1); + } + sort($found, SORT_STRING); + + return $found; + } +} diff --git a/tools/apidocs/src/Markdown.php b/tools/apidocs/src/Markdown.php new file mode 100644 index 0000000..2f3f320 --- /dev/null +++ b/tools/apidocs/src/Markdown.php @@ -0,0 +1,292 @@ +, + * prev: Link|null, + * next: Link|null, + * up: Link|null, + * doc: array|null, + * children: list, + * intro: string + * } + */ +final class Markdown +{ + public const SCHEMA = 'wptoolkit-api/1'; + + /** + * @param Closure(string, string): (string|null) $classLink (name, fromDirectory) => relative path of a documented class. + * @param Closure(int, string): (string|null) $adrLink (number, fromDirectory) => relative path of an ADR. + */ + public function __construct(private readonly Closure $classLink, private readonly Closure $adrLink) + { + } + + /** + * @param Page $page + */ + public function render(array $page): string + { + $directory = dirname($page['path']) === '.' ? '' : dirname($page['path']); + $out = $this->frontMatter($page) . "\n"; + + $out .= match ($page['type']) { + 'class' => $this->classBody($page, $directory), + default => $this->indexBody($page, $directory), + }; + + return rtrim($out) . "\n\n" . $this->footer($page) . "\n"; + } + + /** + * @param Page $page + */ + private function frontMatter(array $page): string + { + $fields = [ + 'schema' => self::string(self::SCHEMA), + 'title' => self::string($page['title']), + 'type' => self::string($page['type'] === 'class' ? (string) ($page['doc']['kind'] ?? 'class') : $page['type']), + ]; + if ($page['fqcn'] !== '') { + $fields['fqcn'] = self::string($page['fqcn']); + } + if ($page['namespace'] !== '') { + $fields['namespace'] = self::string($page['namespace']); + } + $fields['layer'] = self::string($page['layer']); + if ($page['source'] !== '') { + $fields['source'] = self::string($page['source']); + } + if ($page['type'] === 'class') { + $fields['since'] = self::string($page['since']); + $fields['stability'] = self::string($page['stability']); + } + $fields['order'] = (string) $page['order']; + $fields['summary'] = self::string($page['summary']); + if ($page['adrs'] !== []) { + $fields['related'] = '{ adr: [' . implode(', ', $page['adrs']) . '] }'; + } + foreach (['prev', 'next', 'up'] as $link) { + if ($page[$link] !== null) { + $fields[$link] = '{ title: ' . self::string($page[$link]['title']) . ', path: ' . self::string($page[$link]['path']) . ' }'; + } + } + + $lines = ['---']; + foreach ($fields as $name => $value) { + $lines[] = $name . ': ' . $value; + } + $lines[] = '---'; + + return implode("\n", $lines) . "\n"; + } + + /** + * @param Page $page + */ + private function classBody(array $page, string $directory): string + { + $doc = $page['doc'] ?? []; + $out = '# ' . $page['title'] . "\n\n"; + $out .= '`' . $doc['declaration'] . ' ' . $doc['fqcn'] . '`' . $this->heritage($doc, $directory) . "\n\n"; + + if (($doc['deprecated'] ?? null) !== null) { + $out .= '> **Deprecated.** ' . $doc['deprecated'] . "\n\n"; + } + if ($doc['prose'] !== '') { + $out .= $doc['prose'] . "\n\n"; + } + + $facts = ['Source `' . $page['source'] . '`', 'Since ' . $page['since']]; + $decisions = []; + foreach ($page['adrs'] as $number) { + $path = ($this->adrLink)($number, $directory); + $label = sprintf('ADR-%04d', $number); + $decisions[] = $path === null ? $label : '[' . $label . '](' . $path . ')'; + } + if ($decisions !== []) { + $facts[] = 'Decisions: ' . implode(', ', $decisions); + } + $out .= implode(' · ', $facts) . "\n\n"; + + if ($doc['cases'] !== []) { + $out .= "## Cases\n\n| Case | Value |\n| --- | --- |\n"; + foreach ($doc['cases'] as $case) { + $out .= '| `' . $case['name'] . '` | ' . ($case['value'] === '' ? '' : '`' . self::cell($case['value']) . '`') . " |\n"; + } + $out .= "\n"; + } + if ($doc['constants'] !== []) { + $out .= "## Constants\n\n| Constant | Value | Description |\n| --- | --- | --- |\n"; + foreach ($doc['constants'] as $constant) { + $out .= '| `' . $constant['name'] . '` | `' . self::cell($constant['value']) . '` | ' . self::cell($constant['summary']) . " |\n"; + } + $out .= "\n"; + } + if ($doc['properties'] !== []) { + $out .= "## Properties\n\n| Property | Type | Description |\n| --- | --- | --- |\n"; + foreach ($doc['properties'] as $property) { + $out .= '| `$' . $property['name'] . '`' . ($property['readonly'] ? ' (readonly)' : '') . ' | ' + . $this->type($property['type'], $directory) . ' | ' . self::cell($property['summary']) . " |\n"; + } + $out .= "\n"; + } + if ($doc['methods'] !== []) { + $out .= "## Methods\n\n"; + foreach ($doc['methods'] as $method) { + $out .= $this->method($method, $directory); + } + } + + return $out; + } + + /** + * @param array $method + */ + private function method(array $method, string $directory): string + { + $out = '### `' . $method['name'] . "()`\n\n```php\n" . $method['signature'] . ";\n```\n\n"; + if ($method['deprecated'] !== null) { + $out .= '> **Deprecated.** ' . $method['deprecated'] . "\n\n"; + } + if ($method['prose'] !== '') { + $out .= $method['prose'] . "\n\n"; + } + + if ($method['params'] !== []) { + $out .= "| Parameter | Type | Description |\n| --- | --- | --- |\n"; + foreach ($method['params'] as $param) { + $out .= '| `' . $param['name'] . '` | ' . $this->type($param['type'], $directory) . ' | ' . self::cell($param['text']) . " |\n"; + } + $out .= "\n"; + } + if ($method['returns'] !== null) { + $out .= '**Returns** ' . $this->type($method['returns']['type'], $directory) + . ($method['returns']['text'] !== '' ? ' — ' . $method['returns']['text'] : '') . "\n\n"; + } + foreach ($method['throws'] as $throws) { + $out .= '**Throws** ' . $this->type($throws['type'], $directory) . ($throws['text'] !== '' ? ' — ' . $throws['text'] : '') . "\n\n"; + } + + return $out; + } + + /** + * @param array $doc + */ + private function heritage(array $doc, string $directory): string + { + $parts = []; + if ($doc['extends'] !== null) { + $parts[] = 'extends ' . $this->name($doc['extends'], $directory, $doc['short']); + } + if ($doc['implements'] !== []) { + $parts[] = 'implements ' . implode(', ', array_map(fn (string $name): string => $this->name($name, $directory, $doc['short']), $doc['implements'])); + } + + return $parts === [] ? '' : ' — ' . implode(', ', $parts); + } + + /** + * @param Page $page + */ + private function indexBody(array $page, string $directory): string + { + $out = '# ' . $page['title'] . "\n\n" . $page['intro'] . "\n\n"; + $out .= "| Page | Kind | Description |\n| --- | --- | --- |\n"; + foreach ($page['children'] as $child) { + $out .= '| [' . $child['title'] . '](' . $child['path'] . ') | ' . $child['kind'] . ' | ' . self::cell($child['summary']) . " |\n"; + } + + return $out; + } + + /** + * @param Page $page + */ + private function footer(array $page): string + { + $parts = []; + if ($page['prev'] !== null) { + $parts[] = '← [Previous: ' . $page['prev']['title'] . '](' . $page['prev']['path'] . ')'; + } + if ($page['up'] !== null) { + $parts[] = '[Up: ' . $page['up']['title'] . '](' . $page['up']['path'] . ')'; + } + if ($page['next'] !== null) { + $parts[] = '[Next: ' . $page['next']['title'] . '](' . $page['next']['path'] . ') →'; + } + + return "---\n\n" . implode(' · ', $parts); + } + + /** + * A type for a table cell: a documented class becomes a link, anything else stays code. + */ + private function type(string $type, string $directory): string + { + $bare = ltrim($type, '?'); + $path = ($this->classLink)($bare, $directory); + + return $path === null ? '`' . self::cell($type) . '`' : '[`' . self::cell($type) . '`](' . $path . ')'; + } + + /** + * @param string $own The short name of the class being described: a parent or interface with the + * same name (`Foundation\Container` implements `Contracts\Container`) is shown in full. + */ + private function name(string $name, string $directory, string $own = ''): string + { + $path = ($this->classLink)($name, $directory); + $short = str_contains($name, '\\') ? substr($name, (int) strrpos($name, '\\') + 1) : $name; + if ($short === $own) { + $short = $name; + } + + return $path === null ? '`' . $short . '`' : '[`' . $short . '`](' . $path . ')'; + } + + private static function string(string $value): string + { + return (string) json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + /** + * Safe inside a table cell: no pipes, no line breaks. + */ + private static function cell(string $text): string + { + return trim((string) preg_replace('/\s+/', ' ', str_replace('|', '\\|', $text))); + } +}