From fc220ec5b5a9b33865f4cc40492781bba3f103d4 Mon Sep 17 00:00:00 2001
From: Mateusz Charytoniuk <mateusz.charytoniuk@protonmail.com>
Date: Tue, 6 Feb 2024 21:49:09 +0100
Subject: [PATCH] chore: document validators, fix caching issue

---
 .../features/validation/constraints/index.md  | 281 ++++++++++++++++++
 .../features/validation/form-models/index.md  | 121 ++++++++
 .../http-controller-parameters/index.md       | 120 ++++++++
 docs/pages/docs/features/validation/index.md  | 212 +------------
 src/Command/StaticPagesBuild.php              |   3 +
 src/Command/StaticPagesDumpContent.php        |   3 +
 src/Command/StaticPagesMakeEmbeddings.php     |   3 +
 src/Feature.php                               |   1 +
 src/InputValidator/FrontMatterValidator.php   |   3 +
 src/InputValidatorController.php              |   2 -
 .../InputValidatorCollectionProvider.php      |  10 -
 .../InputValidatorControllerProvider.php      |  38 +++
 src/StaticPageChunkIterator.php               |   2 +
 src/StaticPageContentRenderer.php             |   2 +
 src/StaticPageMarkdownParser.php              |   2 +
 src/StaticPageProcessor.php                   |   2 +
 src/StaticPageSitemapGenerator.php            |   2 +
 17 files changed, 584 insertions(+), 223 deletions(-)
 create mode 100644 docs/pages/docs/features/validation/constraints/index.md
 create mode 100644 docs/pages/docs/features/validation/form-models/index.md
 create mode 100644 docs/pages/docs/features/validation/http-controller-parameters/index.md
 create mode 100644 src/SingletonProvider/InputValidatorControllerProvider.php

diff --git a/docs/pages/docs/features/validation/constraints/index.md b/docs/pages/docs/features/validation/constraints/index.md
new file mode 100644
index 00000000..e35782de
--- /dev/null
+++ b/docs/pages/docs/features/validation/constraints/index.md
@@ -0,0 +1,281 @@
+---
+collections: 
+    - name: documents
+      next: docs/features/validation/form-models/index
+layout: dm:document
+next: docs/features/validation/form-models/index
+parent: docs/features/validation/index
+title: Constraints Schema
+description: >
+    Learn how to build validation schemas to check your incoming data.
+---
+
+# Constraints Schema
+
+Resonance provides validation constraints that are roughly equivalent to what
+[JSON Schema](https://json-schema.org/) offers.
+
+They are written in PHP, but they are also convertible into JSON Schema 
+(they feature is also used internally by Resonance's 
+{{docs/features/openapi/index}} schema generator). If you need to, you can
+export your schemas to JSON.
+
+# Usage
+
+All constraints are in `Distantmagic\Resonance\Constraint` namespace.
+
+## Schema
+
+### Any 
+
+```php
+new AnyConstraint();
+```
+```json
+{}
+```
+
+Accepts any value.
+
+### Any of
+
+```php
+/**
+ * @var array<Constraint> $anyOf 
+ */
+new AnyOfConstraint(anyOf: $anyOf);
+```
+```json
+{ 
+    "anyOf": [...] 
+}
+```
+
+Acceepts a value if it passess any of the listed constraints.
+
+### Boolean
+
+```php
+new BooleanConstraint();
+```
+```json
+{ 
+    "type": "boolean"
+}
+```
+
+### Const
+
+```php
+/**
+ * @var int|float|string $constValue
+ */
+new ConstConstraint(constValue: $constValue);
+```
+```json
+{ 
+    "const": ...
+}
+```
+
+Accepts exactly the provided value
+
+### Enum
+
+```php
+/**
+ * @var array<string>|list<string> $values
+ */
+new EnumConstraint(values: $values);
+```
+```json
+{ 
+    "type": "string",
+    "enum": [...]
+}
+```
+
+### Integer
+
+```php
+new IntegerConstraint();
+```
+```json
+{ 
+    "type": "integer"
+}
+```
+
+### List
+
+```php
+/**
+ * @var Constraint $constraint
+ */
+new ListConstraint(valueConstraint: $constraint);
+```
+```json
+{ 
+    "type": "array", 
+    "items": ... 
+}
+```
+
+Accepts an array only if each array's item validates agains the constraint.
+
+### Map
+
+```php
+/**
+ * @var Constraint $constraint
+ */
+new MapConstraint(valueConstraint: $constraint);
+```
+```json
+{ 
+    "type": "object", 
+    "additionalProperties": ... 
+}
+```
+
+Accepts an object if each property validates agains the constraint.
+
+### Number
+
+```php
+new NumberConstraint();
+```
+```json
+{ 
+    "type": "number"
+}
+```
+
+Accepts both integer and float values.
+
+### Object
+
+```php
+/**
+ * @var array<non-empty-string,Constraint> $properties
+ */
+new ObjectConstraint(properties: $properties);
+```
+```json
+{ 
+    "type": "object", 
+    "properties": ... 
+}
+```
+
+Expects an object with exactly the listed properties.
+
+### String
+
+```php
+new StringConstraint();
+```
+```json
+{ 
+    "type": "string",
+    "minLength": 1
+}
+```
+
+### Tuple
+
+```php
+/**
+ * @var list<Constraint> $items
+ */
+new TupleConstraint(items: $items);
+```
+```json
+{ 
+    "type": "array",
+    "items": false,
+    "prefixItems": ...
+}
+```
+
+Expects an array of exactly the provided shape and length.
+
+## Exporting to JSON Schema
+
+You can use `toJsonSchema()` method. Every constraint has it:
+
+```php
+$constraint = new TupleConstraint([
+    new StringConstraint(),
+    new NumberConstraint(),
+]);
+
+$constraint->toJsonSchema();
+```
+
+Produces:
+
+```php
+[
+    'type' => 'array',
+    'items' => false,
+    'prefixItems' => [
+        [
+            'type' => 'string',
+            'minLength' => 1,
+        ],
+        [
+            'type' => 'number',
+        ],
+    ],
+]
+```
+
+## Validation Errors
+
+After validation you can inspect the returned `ConstraintResult` object to 
+check for error messages and the mapped data. Validators always cast data
+to associative arrays.
+
+```php
+$constraint = new ListConstraint(
+    valueConstraint: new StringConstraint()
+);
+
+$validatedResult = $constraint->validate(['hi', 5]);
+
+if (!$validatedResult->status->isValid()) {
+    /**
+     * Errors are indexed by the field name, value is the error code.
+     * 
+     * @var Map<string,non-empty-string> $errors
+     */
+    $errors = $validatedResult->getErrors();
+}
+```
+
+Possible error codes are:
+
+- `invalid_data_type`
+- `invalid_enum_value`
+- `invalid_format`
+- `invalid_nested_constraint`
+- `missing_property`
+- `ok`
+- `unexpected_property`
+
+## Examples
+
+You can compose constraints together:
+
+```php
+$constraint = new ObjectConstraint([
+    'host' => new StringConstraint(),
+    'port' => new IntegerConstraint(),
+]);
+
+$constraint->validate([
+    'host' => 'http://example.com',
+    'port' => 3306,
+]);
+```
diff --git a/docs/pages/docs/features/validation/form-models/index.md b/docs/pages/docs/features/validation/form-models/index.md
new file mode 100644
index 00000000..110e775a
--- /dev/null
+++ b/docs/pages/docs/features/validation/form-models/index.md
@@ -0,0 +1,121 @@
+---
+collections: 
+    - name: documents
+      next: docs/features/validation/http-controller-parameters/index
+layout: dm:document
+next: docs/features/validation/http-controller-parameters/index
+parent: docs/features/validation/index
+title: Form Models
+description: >
+    Validate HTML forms, incoming WebSocket messages and more.
+---
+
+# Form Models
+
+Resonance can map incoming data into models representing a validated data.
+
+It is a useful abstraction that allows you to make sure you are using validated
+data in your application.
+
+# Usage
+
+We will use `BlogPostCreateForm` as an example. It represents a request to
+create a blog post:
+
+```php
+<?php
+
+namespace App\InputValidatedData;
+
+use Distantmagic\Resonance\InputValidatedData;
+
+readonly class BlogPostCreateForm extends InputValidatedData
+{
+    public function __construct(
+        public string $content,
+        public string $title,
+    ) {}
+}
+
+```
+
+## Validators
+
+Validators take in any data and check if it adheres to the configuration 
+schema. The `getConstraint()` method must return a 
+{{docs/features/validation/constraints/index}} object.
+
+```php
+<?php
+
+namespace App\InputValidator;
+
+use App\InputValidatedData\BlogPostCreateForm;
+use Distantmagic\Resonance\Attribute\Singleton;
+use Distantmagic\Resonance\Constraint;
+use Distantmagic\Resonance\Constraint\ObjectConstraint;
+use Distantmagic\Resonance\Constraint\StringConstraint;
+use Distantmagic\Resonance\InputValidator;
+use Distantmagic\Resonance\SingletonCollection;
+
+/**
+ * @extends InputValidator<BlogPostCreateForm, array{
+ *     content: string,
+ *     title: string,
+ * }>
+ */
+#[Singleton(collection: SingletonCollection::InputValidator)]
+readonly class BlogPostFormValidator extends InputValidator
+{
+    public function castValidatedData(mixed $data): BlogPostCreateForm
+    {
+        return new BlogPostCreateForm(
+            $data['content'],
+            $data['title'],
+        );
+    }
+
+    public function getConstraint(): Constraint
+    {
+        return new ObjectConstraint([
+            'content' => new StringConstraint(),
+            'title' => new StringConstraint()
+        ]);
+    }
+}
+```
+
+Preferably validators should be injected somewhere by the 
+{{docs/features/dependency-injection/index}}, so you don't have to set up their 
+parameters manually. Then you can call their `validateData()` method.
+
+```php
+<?php
+
+use Distantmagic\Resonance\InputValidatorController;
+use Distantmagic\Resonance\InputValidationResult;
+
+$inputValidatorController = new InputValidatorController();
+
+/**
+ * @var InputValidationResult $validationResult
+ */
+$validationResult = $inputValidatorController->validateData($blogPostFormValidator, [
+    'content' => 'test',
+    'title' => 'test',
+]);
+
+// If validation is successful, the errors list is empty and validation data
+// is set.
+assert($validationResult->constraintResult->getErrors()->isEmpty());
+
+/**
+ * It's null if validation failed.
+ * 
+ * @var ?BlogPostCreateForm $validationResult->inputValidatedData
+ */
+assert($validationResult->inputValidatedData);
+```
+
+Validators do not throw an exception since invalid data is not a failure in the
+intended application flow. Instead, it's just a normal situation to handle.
diff --git a/docs/pages/docs/features/validation/http-controller-parameters/index.md b/docs/pages/docs/features/validation/http-controller-parameters/index.md
new file mode 100644
index 00000000..db880938
--- /dev/null
+++ b/docs/pages/docs/features/validation/http-controller-parameters/index.md
@@ -0,0 +1,120 @@
+---
+collections: 
+    - documents
+layout: dm:document
+parent: docs/features/validation/index
+title: Http Controller Parameters
+description: >
+    Validate and map any incoming data into reusable objects.
+---
+
+# HTTP Controller Paramateres
+
+:::note
+This feature only works in HTTP {{docs/features/http/controllers}}. It doesn't
+work with pure {{docs/features/http/responders}}.
+:::
+
+Controllers allow to validate and map incoming HTTP data into reusable objects.
+
+# Usage
+
+## Attributes
+
+You don't have to call the validator manually if you use 
+{{docs/features/http/controllers}}. You can use parameter attributes instead.
+
+For example, if a `MyValidator` exists that returns `MyValidatedData` model, 
+you can add `#[ValidatedRequest(MyValidator::class)]` annotation to the 
+parameter of the `MyValidatedData`. A controller is going to validate the 
+incoming `POST` data using the `MyValidator`, and in case of success, it's 
+going to inject the data model into the parameter:
+
+```php
+// ...
+
+public function handle(
+    #[ValidatedRequest(MyValidator::class)]
+    MyValidatedData $data,
+) {
+    // ...
+}
+
+// ...
+```
+
+## Error Handling
+
+To handle errors, you can create an optional method marked with the
+`#[ValidationErrorsHandler]` attribute. If such a method exists, then will be 
+be called in case of validation failure. 
+
+If no such method exists, the controller is going to return a generic
+`400 Bad Request` response.
+
+:::caution
+`handle` method's arguments are forwarded into the error validation method.
+
+It can only use the parameters that are already resolved in the `handle` method
+plus an extra argument with validation errors (marked by the 
+`#[ValidationErrors]`) and request/response pair.
+
+Adding new arguments to the error handler besides those is going to cause an
+error.
+:::
+
+```php
+<?php
+
+namespace App\HttpResponder;
+
+use App\HttpRouteSymbol;
+use App\InputValidatedData\BlogPostForm;
+use App\InputValidator\BlogPostFormValidator;
+use Distantmagic\Resonance\Attribute\RespondsToHttp;
+use Distantmagic\Resonance\Attribute\Singleton;
+use Distantmagic\Resonance\Attribute\ValidatedRequest;
+use Distantmagic\Resonance\Attribute\ValidationErrors;
+use Distantmagic\Resonance\Attribute\ValidationErrorsHandler;
+use Distantmagic\Resonance\HttpResponder\HttpController;
+use Distantmagic\Resonance\HttpResponderInterface;
+use Distantmagic\Resonance\RequestMethod;
+use Distantmagic\Resonance\SingletonCollection;
+use Ds\Map;
+use Ds\Set;
+use Swoole\Http\Request;
+use Swoole\Http\Response;
+
+#[RespondsToHttp(
+    method: RequestMethod::POST,
+    pattern: '/blog_post/create',
+    routeSymbol: HttpRouteSymbol::BlogPostCreate,
+)]
+#[Singleton(collection: SingletonCollection::HttpResponder)]
+final readonly class BlogPostStore extends HttpController
+{
+    public function handle(
+        #[ValidatedRequest(BlogPostFormValidator::class)]
+        BlogPostForm $blogPostForm,
+    ): HttpResponderInterface {
+        /* inser blog post, redirect, etc */
+        /* ... */
+    }
+
+    /**
+     * @param Map<string,Set<string>> $errors
+     */
+    #[ValidationErrorsHandler]
+    public function handleValidationErrors(
+        Request $request,
+        Response $response,
+        #[ValidationErrors]
+        Map $errors,
+    ): HttpResponderInterface {
+        $response->status(400);
+
+        /* render form with errors  */
+        /* ... */
+    }
+}
+```
diff --git a/docs/pages/docs/features/validation/index.md b/docs/pages/docs/features/validation/index.md
index f8b57462..9e32aec5 100644
--- a/docs/pages/docs/features/validation/index.md
+++ b/docs/pages/docs/features/validation/index.md
@@ -15,214 +15,4 @@ You can use validators to check if any incoming data is correct. For example:
 form data filled by the user, incoming {{docs/features/websockets/index}} 
 message, and others.
 
-# Usage
-
-## Validated Data Models (Form Models)
-
-You should always map the validated data into the validated data models (also 
-known as Form Models in other frameworks). 
-
-They don't need any attributes or any configuration. Validated data is going to
-be mapped onto those models later. 
-
-An example blog post form model may look like this:
-
-```php
-<?php
-
-namespace App\InputValidatedData;
-
-use Distantmagic\Resonance\InputValidatedData;
-
-readonly class BlogPostForm extends InputValidatedData
-{
-    public function __construct(
-        public string $content,
-        public string $title,
-    ) {}
-}
-
-```
-
-## Validators
-
-Validators take in any data and check if it adheres to the configuration 
-schema. The `getConstraint()` method must return a constraints object.
-
-```php
-<?php
-
-namespace App\InputValidator;
-
-use App\InputValidatedData\BlogPostForm;
-use Distantmagic\Resonance\Attribute\Singleton;
-use Distantmagic\Resonance\Constraint;
-use Distantmagic\Resonance\Constraint\ObjectConstraint;
-use Distantmagic\Resonance\Constraint\StringConstraint;
-use Distantmagic\Resonance\InputValidator;
-use Distantmagic\Resonance\SingletonCollection;
-
-/**
- * @extends InputValidator<BlogPostForm, array{
- *     content: string,
- *     title: string,
- * }>
- */
-#[Singleton(collection: SingletonCollection::InputValidator)]
-readonly class BlogPostFormValidator extends InputValidator
-{
-    public function castValidatedData(mixed $data): BlogPostForm
-    {
-        return new BlogPostForm(
-            $data['content'],
-            $data['title'],
-        );
-    }
-
-    public function getConstraint(): Constraint
-    {
-        return new ObjectConstraint([
-            'content' => new StringConstraint(),
-            'title' => new StringConstraint()
-        ]);
-    }
-}
-```
-
-Preferably validators should be injected somewhere by the 
-{{docs/features/dependency-injection/index}}, so you don't have to set up their 
-parameters manually. Then you can call their `validateData()` method.
-
-```php
-<?php
-
-use Distantmagic\Resonance\InputValidatorController;
-use Distantmagic\Resonance\InputValidationResult;
-
-$inputValidatorController = new InputValidatorController();
-
-/**
- * @var InputValidationResult $validationResult
- */
-$validationResult = $inputValidatorController->validateData($blogPostFormValidator, [
-    'content' => 'test',
-    'title' => 'test',
-]);
-
-// If validation is successful, the errors list is empty and validation data
-// is set.
-assert($validationResult->constraintResult->getErrors()->isEmpty());
-
-/**
- * It's null if validation failed.
- * 
- * @var ?BlogPostForm $validationResult->inputValidatedData
- */
-assert($validationResult->inputValidatedData);
-```
-
-Validators do not throw an exception since invalid data is not a failure in the
-intended application flow. Instead, it's just a normal situation to handle.
-
-## Controller Attributes
-
-:::note
-This feature only works in HTTP {{docs/features/http/controllers}}. It doesn't
-work with pure {{docs/features/http/responders}}.
-:::
-
-You don't have to call the validator manually if you use 
-{{docs/features/http/controllers}}. You can use controller parameters instead.
-
-For example, if a `MyValidator` exists that returns `MyValidatedData` model, 
-you can add `#[ValidatedRequest(MyValidator::class)]` annotation to the 
-parameter of the `MyValidatedData`. A controller is going to validate the 
-incoming `POST` data using the `MyValidator`, and in case of success, it's 
-going to inject the data model into the parameter:
-
-```php
-// ...
-
-public function handle(
-    #[ValidatedRequest(MyValidator::class)]
-    MyValidatedData $data,
-) {
-    // ...
-}
-
-// ...
-```
-
-To handle errors, you can create an optional method marked with the
-`#[ValidationErrorsHandler]` attribute. If such a method exists, then will be 
-be called in case of validation failure. 
-
-If no such method exists, the controller is going to return a generic
-`400 Bad Request` response.
-
-:::caution
-`handle` method's arguments are forwarded into the error validation method.
-
-It can only use the parameters that are already resolved in the `handle` method
-plus an extra argument with validation errors (marked by the 
-`#[ValidationErrors]`) and request/response pair.
-
-Adding new arguments to the error handler besides those is going to cause an
-error.
-:::
-
-```php
-<?php
-
-namespace App\HttpResponder;
-
-use App\HttpRouteSymbol;
-use App\InputValidatedData\BlogPostForm;
-use App\InputValidator\BlogPostFormValidator;
-use Distantmagic\Resonance\Attribute\RespondsToHttp;
-use Distantmagic\Resonance\Attribute\Singleton;
-use Distantmagic\Resonance\Attribute\ValidatedRequest;
-use Distantmagic\Resonance\Attribute\ValidationErrors;
-use Distantmagic\Resonance\Attribute\ValidationErrorsHandler;
-use Distantmagic\Resonance\HttpResponder\HttpController;
-use Distantmagic\Resonance\HttpResponderInterface;
-use Distantmagic\Resonance\RequestMethod;
-use Distantmagic\Resonance\SingletonCollection;
-use Ds\Map;
-use Ds\Set;
-use Swoole\Http\Request;
-use Swoole\Http\Response;
-
-#[RespondsToHttp(
-    method: RequestMethod::POST,
-    pattern: '/blog_post/create',
-    routeSymbol: HttpRouteSymbol::BlogPostCreate,
-)]
-#[Singleton(collection: SingletonCollection::HttpResponder)]
-final readonly class BlogPostStore extends HttpController
-{
-    public function handle(
-        #[ValidatedRequest(BlogPostFormValidator::class)]
-        BlogPostForm $blogPostForm,
-    ): HttpResponderInterface {
-        /* inser blog post, redirect, etc */
-        /* ... */
-    }
-
-    /**
-     * @param Map<string,Set<string>> $errors
-     */
-    #[ValidationErrorsHandler]
-    public function handleValidationErrors(
-        Request $request,
-        Response $response,
-        #[ValidationErrors]
-        Map $errors,
-    ): HttpResponderInterface {
-        $response->status(400);
-
-        /* render form with errors  */
-        /* ... */
-    }
-}
-```
+{{docs/features/validation/*/index}}
diff --git a/src/Command/StaticPagesBuild.php b/src/Command/StaticPagesBuild.php
index 2a0eb632..0597f3e6 100644
--- a/src/Command/StaticPagesBuild.php
+++ b/src/Command/StaticPagesBuild.php
@@ -5,8 +5,10 @@ declare(strict_types=1);
 namespace Distantmagic\Resonance\Command;
 
 use Distantmagic\Resonance\Attribute\ConsoleCommand;
+use Distantmagic\Resonance\Attribute\WantsFeature;
 use Distantmagic\Resonance\Command;
 use Distantmagic\Resonance\CoroutineCommand;
+use Distantmagic\Resonance\Feature;
 use Distantmagic\Resonance\StaticPageProcessor;
 use Distantmagic\Resonance\SwooleConfiguration;
 use Symfony\Component\Console\Input\InputInterface;
@@ -16,6 +18,7 @@ use Symfony\Component\Console\Output\OutputInterface;
     name: 'static-pages:build',
     description: 'Generate static pages'
 )]
+#[WantsFeature(Feature::StaticPages)]
 final class StaticPagesBuild extends CoroutineCommand
 {
     public function __construct(
diff --git a/src/Command/StaticPagesDumpContent.php b/src/Command/StaticPagesDumpContent.php
index 8d608d28..4b88730c 100644
--- a/src/Command/StaticPagesDumpContent.php
+++ b/src/Command/StaticPagesDumpContent.php
@@ -5,7 +5,9 @@ declare(strict_types=1);
 namespace Distantmagic\Resonance\Command;
 
 use Distantmagic\Resonance\Attribute\ConsoleCommand;
+use Distantmagic\Resonance\Attribute\WantsFeature;
 use Distantmagic\Resonance\Command;
+use Distantmagic\Resonance\Feature;
 use Distantmagic\Resonance\StaticPageAggregate;
 use Symfony\Component\Console\Input\InputInterface;
 use Symfony\Component\Console\Output\OutputInterface;
@@ -14,6 +16,7 @@ use Symfony\Component\Console\Output\OutputInterface;
     name: 'static-pages:dump-content',
     description: 'Dumps static pages content into JSONL'
 )]
+#[WantsFeature(Feature::StaticPages)]
 final class StaticPagesDumpContent extends Command
 {
     public function __construct(
diff --git a/src/Command/StaticPagesMakeEmbeddings.php b/src/Command/StaticPagesMakeEmbeddings.php
index 8953851b..b948cfc6 100644
--- a/src/Command/StaticPagesMakeEmbeddings.php
+++ b/src/Command/StaticPagesMakeEmbeddings.php
@@ -5,7 +5,9 @@ declare(strict_types=1);
 namespace Distantmagic\Resonance\Command;
 
 use Distantmagic\Resonance\Attribute\ConsoleCommand;
+use Distantmagic\Resonance\Attribute\WantsFeature;
 use Distantmagic\Resonance\Command;
+use Distantmagic\Resonance\Feature;
 use Distantmagic\Resonance\JsonSerializer;
 use Distantmagic\Resonance\LlamaCppClient;
 use Distantmagic\Resonance\LlamaCppEmbeddingRequest;
@@ -20,6 +22,7 @@ use Symfony\Component\Console\Output\OutputInterface;
     name: 'static-pages:make-embeddings',
     description: 'Create embeddings from static pages contents (requires llama.cpp)'
 )]
+#[WantsFeature(Feature::StaticPages)]
 final class StaticPagesMakeEmbeddings extends Command
 {
     private readonly SQLite3 $embeddingsDatabase;
diff --git a/src/Feature.php b/src/Feature.php
index 06dc3eca..e1945149 100644
--- a/src/Feature.php
+++ b/src/Feature.php
@@ -8,6 +8,7 @@ enum Feature implements FeatureInterface
 {
     case OAuth2;
     case Postfix;
+    case StaticPages;
     case SwooleTaskServer;
     case WebSocket;
 
diff --git a/src/InputValidator/FrontMatterValidator.php b/src/InputValidator/FrontMatterValidator.php
index 0b1a8bc0..0c0507cc 100644
--- a/src/InputValidator/FrontMatterValidator.php
+++ b/src/InputValidator/FrontMatterValidator.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace Distantmagic\Resonance\InputValidator;
 
+use Distantmagic\Resonance\Attribute\GrantsFeature;
 use Distantmagic\Resonance\Attribute\Singleton;
 use Distantmagic\Resonance\Constraint;
 use Distantmagic\Resonance\Constraint\AnyOfConstraint;
@@ -12,6 +13,7 @@ use Distantmagic\Resonance\Constraint\EnumConstraint;
 use Distantmagic\Resonance\Constraint\ListConstraint;
 use Distantmagic\Resonance\Constraint\ObjectConstraint;
 use Distantmagic\Resonance\Constraint\StringConstraint;
+use Distantmagic\Resonance\Feature;
 use Distantmagic\Resonance\FrontMatterCollectionReference;
 use Distantmagic\Resonance\InputValidatedData\FrontMatter;
 use Distantmagic\Resonance\InputValidator;
@@ -33,6 +35,7 @@ use RuntimeException;
  *     title: non-empty-string,
  * }>
  */
+#[GrantsFeature(Feature::StaticPages)]
 #[Singleton(collection: SingletonCollection::InputValidator)]
 readonly class FrontMatterValidator extends InputValidator
 {
diff --git a/src/InputValidatorController.php b/src/InputValidatorController.php
index a8e33f33..c41ab01a 100644
--- a/src/InputValidatorController.php
+++ b/src/InputValidatorController.php
@@ -4,10 +4,8 @@ declare(strict_types=1);
 
 namespace Distantmagic\Resonance;
 
-use Distantmagic\Resonance\Attribute\Singleton;
 use Ds\Map;
 
-#[Singleton]
 readonly class InputValidatorController
 {
     /**
diff --git a/src/SingletonProvider/InputValidatorCollectionProvider.php b/src/SingletonProvider/InputValidatorCollectionProvider.php
index 7cb802cf..9b2e34d1 100644
--- a/src/SingletonProvider/InputValidatorCollectionProvider.php
+++ b/src/SingletonProvider/InputValidatorCollectionProvider.php
@@ -8,7 +8,6 @@ use Distantmagic\Resonance\Attribute\RequiresSingletonCollection;
 use Distantmagic\Resonance\Attribute\Singleton;
 use Distantmagic\Resonance\InputValidator;
 use Distantmagic\Resonance\InputValidatorCollection;
-use Distantmagic\Resonance\InputValidatorController;
 use Distantmagic\Resonance\PHPProjectFiles;
 use Distantmagic\Resonance\SingletonCollection;
 use Distantmagic\Resonance\SingletonContainer;
@@ -21,10 +20,6 @@ use Distantmagic\Resonance\SingletonProvider;
 #[Singleton(provides: InputValidatorCollection::class)]
 final readonly class InputValidatorCollectionProvider extends SingletonProvider
 {
-    public function __construct(
-        private InputValidatorController $inputValidatorController,
-    ) {}
-
     public function provide(SingletonContainer $singletons, PHPProjectFiles $phpProjectFiles): InputValidatorCollection
     {
         $inputValidatorCollection = new InputValidatorCollection();
@@ -35,11 +30,6 @@ final readonly class InputValidatorCollectionProvider extends SingletonProvider
                     $singleton::class,
                     $singleton,
                 );
-                $this
-                    ->inputValidatorController
-                    ->cachedConstraints
-                    ->put($singleton, $singleton->getConstraint())
-                ;
             }
         }
 
diff --git a/src/SingletonProvider/InputValidatorControllerProvider.php b/src/SingletonProvider/InputValidatorControllerProvider.php
new file mode 100644
index 00000000..087f2ccc
--- /dev/null
+++ b/src/SingletonProvider/InputValidatorControllerProvider.php
@@ -0,0 +1,38 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Distantmagic\Resonance\SingletonProvider;
+
+use Distantmagic\Resonance\Attribute\Singleton;
+use Distantmagic\Resonance\InputValidatorCollection;
+use Distantmagic\Resonance\InputValidatorController;
+use Distantmagic\Resonance\PHPProjectFiles;
+use Distantmagic\Resonance\SingletonContainer;
+use Distantmagic\Resonance\SingletonProvider;
+use Nette\PhpGenerator\Printer;
+
+/**
+ * @template-extends SingletonProvider<Printer>
+ */
+#[Singleton(provides: InputValidatorController::class)]
+final readonly class InputValidatorControllerProvider extends SingletonProvider
+{
+    public function __construct(
+        private InputValidatorCollection $inputValidatorCollection,
+    ) {}
+
+    public function provide(SingletonContainer $singletons, PHPProjectFiles $phpProjectFiles): InputValidatorController
+    {
+        $inputValidatorController = new InputValidatorController();
+
+        foreach ($this->inputValidatorCollection->inputValidators as $inputValidator) {
+            $inputValidatorController
+                ->cachedConstraints
+                ->put($inputValidator, $inputValidator->getConstraint())
+            ;
+        }
+
+        return $inputValidatorController;
+    }
+}
diff --git a/src/StaticPageChunkIterator.php b/src/StaticPageChunkIterator.php
index f196621d..d3983d95 100644
--- a/src/StaticPageChunkIterator.php
+++ b/src/StaticPageChunkIterator.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace Distantmagic\Resonance;
 
+use Distantmagic\Resonance\Attribute\GrantsFeature;
 use Distantmagic\Resonance\Attribute\Singleton;
 use Generator;
 use IteratorAggregate;
@@ -16,6 +17,7 @@ use League\CommonMark\Node\StringContainerHelper;
 /**
  * @template-implements IteratorAggregate<non-empty-string>
  */
+#[GrantsFeature(Feature::StaticPages)]
 #[Singleton]
 readonly class StaticPageChunkIterator implements IteratorAggregate
 {
diff --git a/src/StaticPageContentRenderer.php b/src/StaticPageContentRenderer.php
index 6d629b23..66b67a99 100644
--- a/src/StaticPageContentRenderer.php
+++ b/src/StaticPageContentRenderer.php
@@ -4,8 +4,10 @@ declare(strict_types=1);
 
 namespace Distantmagic\Resonance;
 
+use Distantmagic\Resonance\Attribute\GrantsFeature;
 use Distantmagic\Resonance\Attribute\Singleton;
 
+#[GrantsFeature(Feature::StaticPages)]
 #[Singleton]
 readonly class StaticPageContentRenderer
 {
diff --git a/src/StaticPageMarkdownParser.php b/src/StaticPageMarkdownParser.php
index 224cd062..fa62488e 100644
--- a/src/StaticPageMarkdownParser.php
+++ b/src/StaticPageMarkdownParser.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace Distantmagic\Resonance;
 
+use Distantmagic\Resonance\Attribute\GrantsFeature;
 use Distantmagic\Resonance\Attribute\Singleton;
 use League\CommonMark\Environment\Environment;
 use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
@@ -15,6 +16,7 @@ use League\CommonMark\Extension\GithubFlavoredMarkdownExtension;
 use League\CommonMark\Extension\HeadingPermalink\HeadingPermalinkExtension;
 use League\CommonMark\MarkdownConverter;
 
+#[GrantsFeature(Feature::StaticPages)]
 #[Singleton]
 readonly class StaticPageMarkdownParser
 {
diff --git a/src/StaticPageProcessor.php b/src/StaticPageProcessor.php
index ccaee1eb..6aa4f782 100644
--- a/src/StaticPageProcessor.php
+++ b/src/StaticPageProcessor.php
@@ -4,10 +4,12 @@ declare(strict_types=1);
 
 namespace Distantmagic\Resonance;
 
+use Distantmagic\Resonance\Attribute\GrantsFeature;
 use Distantmagic\Resonance\Attribute\Singleton;
 use Symfony\Component\Filesystem\Filesystem;
 use Symfony\Component\Finder\Finder;
 
+#[GrantsFeature(Feature::StaticPages)]
 #[Singleton]
 readonly class StaticPageProcessor
 {
diff --git a/src/StaticPageSitemapGenerator.php b/src/StaticPageSitemapGenerator.php
index 1dbd0f39..97fedfa9 100644
--- a/src/StaticPageSitemapGenerator.php
+++ b/src/StaticPageSitemapGenerator.php
@@ -4,8 +4,10 @@ declare(strict_types=1);
 
 namespace Distantmagic\Resonance;
 
+use Distantmagic\Resonance\Attribute\GrantsFeature;
 use Distantmagic\Resonance\Attribute\Singleton;
 
+#[GrantsFeature(Feature::StaticPages)]
 #[Singleton]
 readonly class StaticPageSitemapGenerator
 {
-- 
GitLab