This commit is contained in:
Javier Casares 2026-08-24 11:42:10 +00:00
commit 80a2202d6e
18 changed files with 305 additions and 122 deletions

View file

@ -1,5 +1,38 @@
== Changelog == == Changelog ==
= 1.2.2 =
_Release date: 2026-08-24_
**Highlights**
* Manager (by ROBOTSTXT) detection now uses the ecosystem presence constant ROBOTSTXT_MANAGER_NOTICED (Manager 1.6.2+), with a plugin-list scan fallback for older Manager versions
**Changed**
* `robotstxt_docmd_is_manager_active()` now checks the ROBOTSTXT_MANAGER_NOTICED presence constant (Manager 1.6.2+) and falls back to scanning the plugin list (get_plugins() + is_plugin_active()) when the constant is absent; same function name and return type, so callers are unaffected
**Security**
* `composer update` (phpstan 2.2.9, nette/schema 1.3.6 — dev tooling only); `composer audit` clean, no known CVEs
**Compatibility**
* WordPress: 4.2 - 7.1 (floor re-verified with WP-Compat; smoke-tested on 7.2-alpha)
* PHP: 8.0 - 8.5 (floor re-verified with PHPCompatibility 5.6-8.5 full-range scan)
**Translations**
* Spanish (es_ES) and Catalan (ca): verified complete and in sync (156 strings, no changes needed)
**Tests**
* PHP Coding Standards: PHPCS with WordPress-Core, WordPress-Docs, WordPress-Extra — 0 errors
* PHPStan: level 9, 0 errors
* PHPCompatibility: PHP 8.0-8.5 validated (full-range scan 5.6-8.5)
* WP-Compat: WordPress 4.2 floor validated
* PHPUnit: 23 tests pass (17 plugin header tests + 6 new Manager-detection tests covering both detection paths)
= 1.2.1 = = 1.2.1 =
_Release date: 2026-08-17_ _Release date: 2026-08-17_

View file

@ -4,7 +4,7 @@ Tags: github, documentation, markdown, sync, automation
Requires at least: 4.2 Requires at least: 4.2
Tested up to: 7.1 Tested up to: 7.1
Requires PHP: 8.0 Requires PHP: 8.0
Stable tag: 1.2.1 Stable tag: 1.2.2
License: GPLv3 or later License: GPLv3 or later
License URI: https://www.gnu.org/licenses/gpl-3.0.html License URI: https://www.gnu.org/licenses/gpl-3.0.html
@ -189,6 +189,22 @@ Then go to Documentation → Settings, and you'll see a "Debug Tools" section at
For the complete changelog, see [changelog.txt](https://www.robotstxt.software/plugins/robotstxt-documentation-markdown/). For the complete changelog, see [changelog.txt](https://www.robotstxt.software/plugins/robotstxt-documentation-markdown/).
= 1.2.2 - 2026-08-24 =
**Changed**
* Manager (by ROBOTSTXT) detection now uses the ecosystem presence constant `ROBOTSTXT_MANAGER_NOTICED` (Manager 1.6.2+), falling back to a plugin-list scan for older Manager versions — same function name and behavior for callers
* New unit tests for both detection paths (constant defined / plugin-list fallback)
**Compatibility**
* WordPress: 4.2 - 7.1 (floor re-verified with WP-Compat; smoke-tested on 7.2-alpha)
* PHP: 8.0 - 8.5 (floor re-verified with PHPCompatibility 5.6-8.5 full-range scan)
**Translations**
* Spanish (es_ES) and Catalan (ca): verified complete and in sync (156 strings, no changes needed)
= 1.2.1 - 2026-08-17 = = 1.2.1 - 2026-08-17 =
**Changed** **Changed**
@ -238,42 +254,11 @@ For the complete changelog, see [changelog.txt](https://www.robotstxt.software/p
* Inline `onclick` confirm on Delete replaced with a `data-confirm` attribute + delegated handler (CSP-friendlier, less fragile) * Inline `onclick` confirm on Delete replaced with a `data-confirm` attribute + delegated handler (CSP-friendlier, less fragile)
* Add Mapping "existing content" dropdown now queries only public post types (excludes attachments and the internal mapping CPT) with `no_found_rows` for better performance on large sites * Add Mapping "existing content" dropdown now queries only public post types (excludes attachments and the internal mapping CPT) with `no_found_rows` for better performance on large sites
**Compatibility**
* Minimum WordPress raised to 6.8 (latest stable + two previous majors)
* Verified compatible with WordPress 7.1
* Declared PHP minimum made consistent across `readme.txt`, `composer.json`, `phpstan.neon`, and the PHPCompatibility scan range — real minimum remains 8.0
= 1.1.1 - 2026-06-08 =
**Security**
* CommonMark: disabled raw HTML passthrough (`html_input: strip`, `allow_unsafe_links: false`) — prevents stored XSS via a compromised upstream repository
* Settings option now stored with `autoload = false` — encrypted GitHub token no longer loaded on every WordPress request
* Replaced `serialize()` with `wp_json_encode()` in updater HMAC cache signature
* Added strict base64 length validation before IV extraction in token decryption
* Added `base64_decode()` return-value check in GitHub file content fetch
**Fixed**
* Admin notices now display results for all operations: mapping created, updated, deleted, sync complete, and errors — previously only "Settings saved" was shown
* `wp_update_post()` return value now checked during sync — silent failures no longer reported as success
* Wrong textdomain in updater class (`'robotstxt-smtp'` → `'robotstxt-documentation-markdown'`)
**Changed**
* Renamed `robotstxt-updater.php` → `class-robotstxt-updater.php` (WordPress file naming convention)
* Minimum PHP version corrected to 8.0 (real minimum confirmed by PHPCompatibility scan — no PHP 8.1 or 8.2 specific features used)
**Developer**
* Added full tooling: `composer.json`, `phpstan.neon` (level 9), `.phpcs.xml`, `bin/deploy.sh`, `phpunit.xml.dist`
* PHPCompatibility updated to 10.0.0-alpha2 (PHP 8.x feature detection)
* PHPUnit: 17 plugin header tests added
* PHPCS, PHPStan level 9, PHPUnit all pass with 0 errors
== Upgrade Notice == == Upgrade Notice ==
= 1.2.2 =
Manager (by ROBOTSTXT) detection updated: now uses the ROBOTSTXT_MANAGER_NOTICED presence constant (Manager 1.6.2+) with a fallback for older Manager versions.
= 1.2.1 = = 1.2.1 =
Automatic updates now require the Manager (by ROBOTSTXT) plugin. A notice with the download link is shown on the Plugins and Settings pages. Automatic updates now require the Manager (by ROBOTSTXT) plugin. A notice with the download link is shown on the Plugins and Settings pages.

View file

@ -3,7 +3,7 @@
* Plugin Name: Documentation Markdown (by ROBOTSTXT) * Plugin Name: Documentation Markdown (by ROBOTSTXT)
* Plugin URI: https://www.robotstxt.software/plugins/robotstxt-documentation-markdown/ * Plugin URI: https://www.robotstxt.software/plugins/robotstxt-documentation-markdown/
* Description: Synchronizes Markdown documentation from GitHub repositories to WordPress pages and posts automatically. * Description: Synchronizes Markdown documentation from GitHub repositories to WordPress pages and posts automatically.
* Version: 1.2.1 * Version: 1.2.2
* Requires at least: 4.2 * Requires at least: 4.2
* Requires PHP: 8.0 * Requires PHP: 8.0
* Security: robotstxt@robotstxt.es * Security: robotstxt@robotstxt.es
@ -25,7 +25,7 @@ if ( ! defined( 'ABSPATH' ) ) {
} }
// Define plugin constants. // Define plugin constants.
define( 'ROBOTSTXT_DOCMD_VERSION', '1.2.1' ); define( 'ROBOTSTXT_DOCMD_VERSION', '1.2.2' );
define( 'ROBOTSTXT_DOCMD_PLUGIN_FILE', __FILE__ ); define( 'ROBOTSTXT_DOCMD_PLUGIN_FILE', __FILE__ );
define( 'ROBOTSTXT_DOCMD_PLUGIN_DIR', plugin_dir_path( __FILE__ ) ); define( 'ROBOTSTXT_DOCMD_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
define( 'ROBOTSTXT_DOCMD_PLUGIN_URL', plugin_dir_url( __FILE__ ) ); define( 'ROBOTSTXT_DOCMD_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
@ -1676,17 +1676,41 @@ function robotstxt_docmd_debug_fix_crons() {
} }
/** /**
* Check whether the Manager (by ROBOTSTXT) plugin is active * Check whether the Manager (by ROBOTSTXT) plugin is installed and active
* *
* Updates for this plugin are delivered through the Manager plugin. This * Updates for this plugin are delivered through the Manager plugin. This
* helper detects whether it is installed and active. * helper detects whether it is installed and active: it uses the ecosystem
* presence constant (Manager 1.6.2+) and falls back to a plugin-list scan
* for older Manager versions.
* *
* @since 1.2.1 * @since 1.2.1
* @since 1.2.2 Switched to the ROBOTSTXT_MANAGER_NOTICED presence constant
* with a plugin-list scan fallback for Manager < 1.6.2.
* *
* @return bool True if Manager (by ROBOTSTXT) is active, false otherwise. * @return bool True if Manager (by ROBOTSTXT) is active, false otherwise.
*/ */
function robotstxt_docmd_is_manager_active(): bool { function robotstxt_docmd_is_manager_active(): bool {
return defined( 'ROBOTSTXT_MANAGER_VERSION' ); if ( defined( 'ROBOTSTXT_MANAGER_NOTICED' ) && ROBOTSTXT_MANAGER_NOTICED ) {
return true;
}
if ( ! function_exists( 'get_plugins' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
foreach ( get_plugins() as $file => $data ) {
$slug = dirname( $file );
if ( '.' === $slug ) {
$slug = basename( $file, '.php' );
}
if ( 'robotstxt-manager' === $slug ) {
return is_plugin_active( $file );
}
}
return false;
} }
/** /**

View file

@ -1,27 +0,0 @@
{
"name": "Documentation Markdown (by ROBOTSTXT)",
"slug": "robotstxt-documentation-markdown",
"version": "1.2.1",
"download_url": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-documentation-markdown/releases/download/1.2.1/robotstxt-documentation-markdown-1.2.1.zip",
"requires": "6.8",
"requires_php": "8.0",
"tested": "7.1",
"last_updated": "2026-08-07",
"author": "ROBOTSTXT",
"author_profile": "https://www.robotstxt.es/",
"homepage": "https://git.robotstxt.es/ROBOTSTXT/robotstxt-documentation-markdown",
"description": "Synchronizes Markdown documentation from GitHub repositories to WordPress pages and posts automatically. Perfect for maintaining technical documentation, API references, knowledge bases, and more with version control.",
"changelog": "",
"sections": {
"description": "<p><strong>Documentation Markdown</strong> is a powerful WordPress plugin that enables seamless synchronization of Markdown documentation files from GitHub repositories into your WordPress site.</p><h4>Key Features</h4><ul><li><strong>Automatic Synchronization:</strong> Schedule automatic syncs via WordPress Cron (hourly, twice daily, daily)</li><li><strong>Markdown to HTML:</strong> Convert GitHub Flavored Markdown to clean HTML using CommonMark</li><li><strong>Flexible Mapping:</strong> Map individual MD files to specific WordPress posts or pages</li><li><strong>Title from H1:</strong> Post titles are derived from the first Markdown H1 heading</li><li><strong>Link &amp; Image Translation:</strong> Repo-relative links are rewritten to permalinks; images are sideloaded into the Media Library</li><li><strong>Secure:</strong> Encrypted GitHub token storage (HKDF-SHA256), full input validation &amp; output escaping</li><li><strong>Translatable:</strong> Full internationalization support (i18n/l10n ready)</li><li><strong>Multi-Repository:</strong> Sync from multiple GitHub repos simultaneously</li><li><strong>Manual Sync:</strong> On-demand synchronization from admin interface</li><li><strong>Debug Tools:</strong> Built-in debugging tools (visible when WP_DEBUG is enabled)</li></ul><h4>Use Cases</h4><ul><li>API Documentation - Keep your API docs in sync between GitHub and WordPress</li><li>Technical Documentation - Maintain version-controlled technical docs</li><li>Knowledge Base - Build a knowledge base powered by GitHub</li><li>Blog Posts - Write blog posts in Markdown with Git workflow</li><li>Product Documentation - Sync product documentation from your repository</li></ul><h4>Requirements</h4><ul><li>PHP 8.0 or higher</li><li>WordPress 6.8 or higher</li><li>GitHub Personal Access Token (free)</li></ul>",
"changelog": ""
},
"banners": {
"low": "",
"high": ""
},
"icons": {
"1x": "",
"2x": ""
}
}

View file

@ -275,17 +275,17 @@
}, },
{ {
"name": "nette/schema", "name": "nette/schema",
"version": "v1.3.5", "version": "v1.3.6",
"version_normalized": "1.3.5.0", "version_normalized": "1.3.6.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/nette/schema.git", "url": "https://github.com/nette/schema.git",
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002" "reference": "c54350438cd6914616f790a49cb424605f421562"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002", "url": "https://api.github.com/repos/nette/schema/zipball/c54350438cd6914616f790a49cb424605f421562",
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002", "reference": "c54350438cd6914616f790a49cb424605f421562",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@ -299,7 +299,7 @@
"phpstan/phpstan": "^2.1.39@stable", "phpstan/phpstan": "^2.1.39@stable",
"tracy/tracy": "^2.8" "tracy/tracy": "^2.8"
}, },
"time": "2026-02-23T03:47:12+00:00", "time": "2026-08-16T21:58:41+00:00",
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
@ -339,7 +339,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/nette/schema/issues", "issues": "https://github.com/nette/schema/issues",
"source": "https://github.com/nette/schema/tree/v1.3.5" "source": "https://github.com/nette/schema/tree/v1.3.6"
}, },
"install-path": "../nette/schema" "install-path": "../nette/schema"
}, },

View file

@ -38,9 +38,9 @@
'dev_requirement' => false, 'dev_requirement' => false,
), ),
'nette/schema' => array( 'nette/schema' => array(
'pretty_version' => 'v1.3.5', 'pretty_version' => 'v1.3.6',
'version' => '1.3.5.0', 'version' => '1.3.6.0',
'reference' => 'f0ab1a3cda782dbc5da270d28545236aa80c4002', 'reference' => 'c54350438cd6914616f790a49cb424605f421562',
'type' => 'library', 'type' => 'library',
'install_path' => __DIR__ . '/../nette/schema', 'install_path' => __DIR__ . '/../nette/schema',
'aliases' => array(), 'aliases' => array(),

View file

@ -126,7 +126,7 @@ Expect::null()
Expect::array($default = []) Expect::array($default = [])
``` ```
And then all types [supported by the Validators](https://doc.nette.org/validators#toc-validation-rules) via `Expect::type('scalar')` or abbreviated `Expect::scalar()`. Also class or interface names are accepted, e.g. `Expect::type('AddressEntity')`. And then all types [supported by the Validators](https://doc.nette.org/validators#toc-expected-types) via `Expect::type('scalar')` or abbreviated `Expect::scalar()`. Also class or interface names are accepted, e.g. `Expect::type('AddressEntity')`.
You can also use union notation: You can also use union notation:

View file

@ -10,6 +10,9 @@ namespace Nette\Schema;
use function count; use function count;
/**
* Accumulates errors and warnings during schema validation and tracks the current path.
*/
final class Context final class Context
{ {
public bool $skipDefaults = false; public bool $skipDefaults = false;
@ -44,7 +47,10 @@ final class Context
} }
/** @return \Closure(): bool */ /**
* Returns a closure that returns true as long as no new errors have been added since the call.
* @return \Closure(): bool
*/
public function createChecker(): \Closure public function createChecker(): \Closure
{ {
$count = count($this->errors); $count = count($this->errors);

View file

@ -14,6 +14,9 @@ use Nette\Schema\Schema;
use function array_merge, array_unique, implode, is_array; use function array_merge, array_unique, implode, is_array;
/**
* Schema that accepts any of a fixed set of values or sub-schemas (union type / enumeration).
*/
final class AnyOf implements Schema final class AnyOf implements Schema
{ {
use Base; use Base;
@ -32,6 +35,9 @@ final class AnyOf implements Schema
} }
/**
* Sets the first variant as the default value (instead of null).
*/
public function firstIsDefault(): self public function firstIsDefault(): self
{ {
$this->default = $this->set[0]; $this->default = $this->set[0];
@ -39,6 +45,9 @@ final class AnyOf implements Schema
} }
/**
* Allows null as an accepted value in addition to the defined variants.
*/
public function nullable(): self public function nullable(): self
{ {
$this->set[] = null; $this->set[] = null;
@ -46,6 +55,9 @@ final class AnyOf implements Schema
} }
/**
* Allows the value to be a DynamicParameter as an accepted variant.
*/
public function dynamic(): self public function dynamic(): self
{ {
$this->set[] = new Type(Nette\Schema\DynamicParameter::class); $this->set[] = new Type(Nette\Schema\DynamicParameter::class);
@ -88,10 +100,13 @@ final class AnyOf implements Schema
foreach ($this->set as $item) { foreach ($this->set as $item) {
if ($item instanceof Schema) { if ($item instanceof Schema) {
$dolly = new Context; $dolly = new Context;
$dolly->skipDefaults = $context->skipDefaults;
$dolly->isKey = $context->isKey;
$dolly->path = $context->path; $dolly->path = $context->path;
$res = $item->complete($item->normalize($value, $dolly), $dolly); $res = $item->complete($item->normalize($value, $dolly), $dolly);
if (!$dolly->errors) { if (!$dolly->errors) {
$context->warnings = array_merge($context->warnings, $dolly->warnings); $context->warnings = array_merge($context->warnings, $dolly->warnings);
$context->dynamics = array_merge($context->dynamics, $dolly->dynamics);
return $res; return $res;
} }

View file

@ -43,7 +43,10 @@ trait Base
} }
/** @param callable(mixed): mixed $handler */ /**
* Sets a pre-normalization callback applied to the raw input value before any validation.
* @param callable(mixed): mixed $handler
*/
public function before(callable $handler): self public function before(callable $handler): self
{ {
$this->before = $handler(...); $this->before = $handler(...);
@ -51,13 +54,19 @@ trait Base
} }
/**
* Casts the validated value to a built-in type or instantiates the given class.
*/
public function castTo(string $type): self public function castTo(string $type): self
{ {
return $this->transform(Helpers::getCastStrategy($type)); return $this->transform(Helpers::getCastStrategy($type));
} }
/** @param callable(mixed, Context): mixed $handler */ /**
* Adds a post-validation transformation callback. The handler may also report errors via Context.
* @param callable(mixed, Context): mixed $handler
*/
public function transform(callable $handler): self public function transform(callable $handler): self
{ {
$this->transforms[] = $handler(...); $this->transforms[] = $handler(...);
@ -65,7 +74,10 @@ trait Base
} }
/** @param callable(mixed): bool $handler */ /**
* Adds a custom validation assertion; optionally describe it for error messages.
* @param callable(mixed): bool $handler
*/
public function assert(callable $handler, ?string $description = null): self public function assert(callable $handler, ?string $description = null): self
{ {
$expected = $description ?? (is_string($handler) ? "$handler()" : '#' . count($this->transforms)); $expected = $description ?? (is_string($handler) ? "$handler()" : '#' . count($this->transforms));
@ -78,11 +90,14 @@ trait Base
Nette\Schema\Message::FailedAssertion, Nette\Schema\Message::FailedAssertion,
['value' => $value, 'assertion' => $expected], ['value' => $value, 'assertion' => $expected],
); );
return null;
}); });
} }
/** Marks as deprecated */ /**
* Marks the item as deprecated; emits a warning with the given message when the item is used.
*/
public function deprecated(string $message = 'The item %path% is deprecated.'): self public function deprecated(string $message = 'The item %path% is deprecated.'): self
{ {
$this->deprecated = $message; $this->deprecated = $message;
@ -138,7 +153,7 @@ trait Base
} }
/** @deprecated use Nette\Schema\Validators::validateType() */ /** @deprecated use Nette\Schema\Helpers::validateType() */
private function doValidate(mixed $value, string $expected, Context $context): bool private function doValidate(mixed $value, string $expected, Context $context): bool
{ {
$isOk = $context->createChecker(); $isOk = $context->createChecker();
@ -148,7 +163,7 @@ trait Base
/** /**
* @deprecated use Nette\Schema\Validators::validateRange() * @deprecated use Nette\Schema\Helpers::validateRange()
* @param array{?float, ?float} $range * @param array{?float, ?float} $range
*/ */
private static function doValidateRange(mixed $value, array $range, Context $context, string $types = ''): bool private static function doValidateRange(mixed $value, array $range, Context $context, string $types = ''): bool

View file

@ -39,6 +39,9 @@ final class Structure implements Schema
} }
/**
* Not supported for structures; always throws.
*/
public function default(mixed $value): self public function default(mixed $value): self
{ {
throw new Nette\InvalidStateException('Structure cannot have default value.'); throw new Nette\InvalidStateException('Structure cannot have default value.');
@ -59,6 +62,9 @@ final class Structure implements Schema
} }
/**
* Allows extra keys not defined in the shape, validating their values against the given type.
*/
public function otherItems(string|Schema $type = 'mixed'): self public function otherItems(string|Schema $type = 'mixed'): self
{ {
$this->otherItems = $type instanceof Schema ? $type : new Type($type); $this->otherItems = $type instanceof Schema ? $type : new Type($type);
@ -66,6 +72,9 @@ final class Structure implements Schema
} }
/**
* When enabled, properties whose value equals the default are omitted from the output.
*/
public function skipDefaults(bool $state = true): self public function skipDefaults(bool $state = true): self
{ {
$this->skipDefaults = $state; $this->skipDefaults = $state;
@ -73,7 +82,10 @@ final class Structure implements Schema
} }
/** @param Schema[]|self $shape */ /**
* Creates a new structure by merging this shape with additional properties.
* @param Schema[]|self $shape
*/
public function extend(array|self $shape): self public function extend(array|self $shape): self
{ {
$shape = $shape instanceof self ? $shape->items : $shape; $shape = $shape instanceof self ? $shape->items : $shape;
@ -203,8 +215,17 @@ final class Structure implements Schema
public function completeDefault(Context $context): mixed public function completeDefault(Context $context): mixed
{ {
return $this->required if (!$this->required) {
? $this->complete([], $context) return null;
: null; }
// the item is missing in the input, do not report it as used deprecated
$deprecated = $this->deprecated;
$this->deprecated = null;
try {
return $this->complete([], $context);
} finally {
$this->deprecated = $deprecated;
}
} }
} }

View file

@ -11,6 +11,7 @@ use Nette\Schema\Context;
use Nette\Schema\DynamicParameter; use Nette\Schema\DynamicParameter;
use Nette\Schema\Helpers; use Nette\Schema\Helpers;
use Nette\Schema\Schema; use Nette\Schema\Schema;
use Nette\Utils\Validators;
use function array_key_exists, array_pop, implode, is_array, str_replace, strpos; use function array_key_exists, array_pop, implode, is_array, str_replace, strpos;
@ -36,6 +37,9 @@ final class Type implements Schema
} }
/**
* Allows the value to be null in addition to the declared type.
*/
public function nullable(): self public function nullable(): self
{ {
$this->type = 'null|' . $this->type; $this->type = 'null|' . $this->type;
@ -43,6 +47,9 @@ final class Type implements Schema
} }
/**
* Controls whether the default value is merged with the input array (enabled by default).
*/
public function mergeDefaults(bool $state = true): self public function mergeDefaults(bool $state = true): self
{ {
$this->merge = $state; $this->merge = $state;
@ -50,6 +57,9 @@ final class Type implements Schema
} }
/**
* Allows the value to be a DynamicParameter, which is recorded for deferred validation.
*/
public function dynamic(): self public function dynamic(): self
{ {
$this->type = DynamicParameter::class . '|' . $this->type; $this->type = DynamicParameter::class . '|' . $this->type;
@ -86,6 +96,9 @@ final class Type implements Schema
} }
/**
* Sets a regex pattern the string value must match entirely (anchored to start and end).
*/
public function pattern(?string $pattern): self public function pattern(?string $pattern): self
{ {
$this->pattern = $pattern; $this->pattern = $pattern;
@ -162,7 +175,7 @@ final class Type implements Schema
$merge = false; $merge = false;
} }
if ($value === null && is_array($this->default)) { if ($value === null && is_array($this->default) && !Validators::is(null, $this->type)) {
$value = []; // is unable to distinguish null from array in NEON $value = []; // is unable to distinguish null from array in NEON
} }
@ -173,13 +186,13 @@ final class Type implements Schema
$isOk() && Helpers::validateRange($value, $this->range, $context, $this->type); $isOk() && Helpers::validateRange($value, $this->range, $context, $this->type);
$isOk() && $value !== null && $this->pattern !== null && Helpers::validatePattern($value, $this->pattern, $context); $isOk() && $value !== null && $this->pattern !== null && Helpers::validatePattern($value, $this->pattern, $context);
$isOk() && is_array($value) && $this->validateItems($value, $context); $isOk() && is_array($value) && $this->validateItems($value, $context);
$isOk() && $merge && $value = Helpers::merge($value, $this->default); $isOk() && $merge && $value !== null && $value = Helpers::merge($value, $this->default);
$isOk() && $value = $this->doTransform($value, $context); $isOk() && $value = $this->doTransform($value, $context);
if (!$isOk()) { if (!$isOk()) {
return null; return null;
} }
if ($value instanceof DynamicParameter) { if ($value instanceof DynamicParameter && $this->type !== DynamicParameter::class) {
$expected = $this->type . ($this->range === [null, null] ? '' : ':' . implode('..', $this->range)); $expected = $this->type . ($this->range === [null, null] ? '' : ':' . implode('..', $this->range));
$context->dynamics[] = [$value, str_replace(DynamicParameter::class . '|', '', $expected), $context->path]; $context->dynamics[] = [$value, str_replace(DynamicParameter::class . '|', '', $expected), $context->path];
} }
@ -190,7 +203,7 @@ final class Type implements Schema
/** @param array<mixed> $value */ /** @param array<mixed> $value */
private function validateItems(array &$value, Context $context): void private function validateItems(array &$value, Context $context): void
{ {
if (!$this->itemsValue) { if (!($itemsValue = $this->itemsValue)) {
return; return;
} }
@ -198,9 +211,15 @@ final class Type implements Schema
foreach ($value as $key => $val) { foreach ($value as $key => $val) {
$context->path[] = $key; $context->path[] = $key;
$context->isKey = true; $context->isKey = true;
$isKeyOk = $context->createChecker();
$key = $this->itemsKey ? $this->itemsKey->complete($key, $context) : $key; $key = $this->itemsKey ? $this->itemsKey->complete($key, $context) : $key;
$context->isKey = false; $context->isKey = false;
$res[$key ?? ''] = $this->itemsValue->complete($val, $context); $keyOk = $isKeyOk();
$val = $itemsValue->complete($val, $context);
if ($keyOk) {
$res[$key] = $val;
}
array_pop($context->path); array_pop($context->path);
} }
$value = $res; $value = $res;

View file

@ -42,26 +42,38 @@ final class Expect
} }
/**
* Creates a schema for a custom type expression (e.g., 'int|string', 'null|float').
*/
public static function type(string $type): Type public static function type(string $type): Type
{ {
return new Type($type); return new Type($type);
} }
/**
* Creates a union schema that accepts any of the given values or sub-schemas.
*/
public static function anyOf(mixed ...$set): AnyOf public static function anyOf(mixed ...$set): AnyOf
{ {
return new AnyOf(...$set); return new AnyOf(...$set);
} }
/** @param Schema[] $shape */ /**
* Creates a structure schema with defined properties; output is stdClass.
* @param Schema[] $shape
*/
public static function structure(array $shape): Structure public static function structure(array $shape): Structure
{ {
return new Structure($shape); return new Structure($shape);
} }
/** @param array<string, Schema> $items */ /**
* Generates a structure schema from a class instance by reflecting its properties or constructor parameters.
* @param array<string, Schema> $items Optional overrides for specific properties.
*/
public static function from(object $object, array $items = []): Structure public static function from(object $object, array $items = []): Structure
{ {
$ro = new \ReflectionObject($object); $ro = new \ReflectionObject($object);
@ -95,6 +107,8 @@ final class Expect
/** /**
* Creates an array schema. When passed Schema elements, behaves like structure() but outputs an array.
* Without Schema elements, creates a plain array type with the given default value.
* @param mixed[] $shape * @param mixed[] $shape
*/ */
public static function array(?array $shape = []): Structure|Type public static function array(?array $shape = []): Structure|Type
@ -106,12 +120,18 @@ final class Expect
} }
/**
* Creates an associative or indexed array schema where every value matches the given type.
*/
public static function arrayOf(string|Schema $valueType, string|Schema|null $keyType = null): Type public static function arrayOf(string|Schema $valueType, string|Schema|null $keyType = null): Type
{ {
return (new Type('array'))->items($valueType, $keyType); return (new Type('array'))->items($valueType, $keyType);
} }
/**
* Creates a list schema (sequentially indexed from 0) where every element matches the given type.
*/
public static function listOf(string|Schema $type): Type public static function listOf(string|Schema $type): Type
{ {
return (new Type('list'))->items($type); return (new Type('list'))->items($type);

View file

@ -9,7 +9,7 @@ namespace Nette\Schema;
use Nette; use Nette;
use Nette\Utils\Reflection; use Nette\Utils\Reflection;
use function count, explode, get_debug_type, implode, in_array, is_array, is_float, is_int, is_object, is_scalar, is_string, method_exists, preg_match, preg_quote, preg_replace, preg_replace_callback, settype, str_replace, strlen, trim, var_export; use function array_map, count, explode, get_debug_type, implode, in_array, is_array, is_float, is_int, is_object, is_scalar, is_string, is_subclass_of, method_exists, preg_match, preg_quote, preg_replace, preg_replace_callback, settype, str_replace, strlen, trim, var_export;
/** /**
@ -54,6 +54,9 @@ final class Helpers
} }
/**
* Returns the type of a property or parameter as a string, or null if not determinable.
*/
public static function getPropertyType(\ReflectionProperty|\ReflectionParameter $prop): ?string public static function getPropertyType(\ReflectionProperty|\ReflectionParameter $prop): ?string
{ {
if ($type = Nette\Utils\Type::fromReflection($prop)) { if ($type = Nette\Utils\Type::fromReflection($prop)) {
@ -89,6 +92,9 @@ final class Helpers
} }
/**
* Formats a value for use in error messages (e.g., 'hello', true, object stdClass).
*/
public static function formatValue(mixed $value): string public static function formatValue(mixed $value): string
{ {
if ($value instanceof DynamicParameter) { if ($value instanceof DynamicParameter) {
@ -105,6 +111,9 @@ final class Helpers
} }
/**
* Adds a TypeMismatch error to the context if the value does not match the expected type.
*/
public static function validateType(mixed $value, string $expected, Context $context): void public static function validateType(mixed $value, string $expected, Context $context): void
{ {
if (!Nette\Utils\Validators::is($value, $expected)) { if (!Nette\Utils\Validators::is($value, $expected)) {
@ -119,7 +128,10 @@ final class Helpers
} }
/** @param array{?float, ?float} $range */ /**
* Adds a range error to the context if the value (or its length for strings/arrays) is outside the given range.
* @param array{?float, ?float} $range
*/
public static function validateRange(mixed $value, array $range, Context $context, string $types = ''): void public static function validateRange(mixed $value, array $range, Context $context, string $types = ''): void
{ {
if (is_array($value) || is_string($value)) { if (is_array($value) || is_string($value)) {
@ -146,7 +158,10 @@ final class Helpers
} }
/** @param array{?float, ?float} $range */ /**
* Checks whether a value falls within the given [min, max] range (null means no bound).
* @param array{?float, ?float} $range
*/
public static function isInRange(mixed $value, array $range): bool public static function isInRange(mixed $value, array $range): bool
{ {
return ($range[0] === null || $value >= $range[0]) return ($range[0] === null || $value >= $range[0])
@ -154,6 +169,9 @@ final class Helpers
} }
/**
* Adds a PatternMismatch error to the context if the value does not match the pattern.
*/
public static function validatePattern(string $value, string $pattern, Context $context): void public static function validatePattern(string $value, string $pattern, Context $context): void
{ {
if (!preg_match("\x01^(?:$pattern)$\x01Du", $value)) { if (!preg_match("\x01^(?:$pattern)$\x01Du", $value)) {
@ -166,7 +184,10 @@ final class Helpers
} }
/** @return \Closure(mixed): mixed */ /**
* Returns a closure that casts a value to the given type (built-in, backed enum, class with constructor, or plain class).
* @return \Closure(mixed, Context): mixed
*/
public static function getCastStrategy(string $type): \Closure public static function getCastStrategy(string $type): \Closure
{ {
if (Nette\Utils\Validators::isBuiltinType($type)) { if (Nette\Utils\Validators::isBuiltinType($type)) {
@ -174,12 +195,37 @@ final class Helpers
settype($value, $type); settype($value, $type);
return $value; return $value;
}; };
} elseif (method_exists($type, '__construct')) {
return static fn($value) => is_array($value) || $value instanceof \stdClass } elseif (is_subclass_of($type, \BackedEnum::class)) {
return static function ($value, Context $context) use ($type) {
try {
return $type::from($value);
} catch (\TypeError | \ValueError) {
$context->addError(
'The %label% %path% expects to be %expected%, %value% given.',
Message::TypeMismatch,
['value' => $value, 'expected' => implode('|', array_map(fn(\BackedEnum $case) => self::formatValue($case->value), $type::cases()))],
);
return null;
}
};
} elseif (is_subclass_of($type, \UnitEnum::class)) {
throw new Nette\InvalidStateException("Cannot cast value to pure enum $type.");
}
$factory = method_exists($type, '__construct')
? static fn($value) => is_array($value) || $value instanceof \stdClass
? new $type(...(array) $value) ? new $type(...(array) $value)
: new $type($value); : new $type($value)
} else { : static fn($value) => Nette\Utils\Arrays::toObject((array) $value, new $type);
return static fn($value) => Nette\Utils\Arrays::toObject((array) $value, new $type);
return static function ($value) use ($factory, $type) {
try {
return $factory($value);
} catch (\Error $e) {
throw new Nette\InvalidStateException("Unable to cast value to $type: " . $e->getMessage(), 0, $e);
} }
};
} }
} }

View file

@ -7,9 +7,12 @@
namespace Nette\Schema; namespace Nette\Schema;
use function implode, preg_replace_callback; use function array_key_exists, implode, preg_replace_callback;
/**
* Represents a single validation error or warning with a message template, error code, path, and variables.
*/
final class Message final class Message
{ {
/** variables: {value: mixed, expected: string} */ /** variables: {value: mixed, expected: string} */
@ -72,6 +75,9 @@ final class Message
} }
/**
* Formats the message template by substituting %variable% placeholders with their values.
*/
public function toString(): string public function toString(): string
{ {
$vars = $this->variables; $vars = $this->variables;
@ -83,7 +89,9 @@ final class Message
return preg_replace_callback('~( ?)%(\w+)%~', function ($m) use ($vars) { return preg_replace_callback('~( ?)%(\w+)%~', function ($m) use ($vars) {
[, $space, $key] = $m; [, $space, $key] = $m;
return $vars[$key] === null ? '' : $space . $vars[$key]; return array_key_exists($key, $vars)
? ($vars[$key] === null ? '' : $space . $vars[$key])
: $m[0];
}, $this->message); }, $this->message);
} }
} }

View file

@ -21,6 +21,9 @@ final class Processor
private bool $skipDefaults = false; private bool $skipDefaults = false;
/**
* When enabled, properties with default values are omitted from the output.
*/
public function skipDefaults(bool $value = true): void public function skipDefaults(bool $value = true): void
{ {
$this->skipDefaults = $value; $this->skipDefaults = $value;
@ -35,9 +38,9 @@ final class Processor
{ {
$this->createContext(); $this->createContext();
$data = $schema->normalize($data, $this->context); $data = $schema->normalize($data, $this->context);
$this->throwsErrors(); $this->throwErrors();
$data = $schema->complete($data, $this->context); $data = $schema->complete($data, $this->context);
$this->throwsErrors(); $this->throwErrors();
return $data; return $data;
} }
@ -54,30 +57,35 @@ final class Processor
$first = true; $first = true;
foreach ($dataset as $data) { foreach ($dataset as $data) {
$data = $schema->normalize($data, $this->context); $data = $schema->normalize($data, $this->context);
$this->throwsErrors(); $this->throwErrors();
$flatten = $first ? $data : $schema->merge($data, $flatten); $flatten = $first ? $data : $schema->merge($data, $flatten);
$first = false; $first = false;
} }
$data = $schema->complete($flatten, $this->context); $data = $schema->complete($flatten, $this->context);
$this->throwsErrors(); $this->throwErrors();
return $data; return $data;
} }
/** @return list<string> */ /**
* Returns all deprecation warnings collected during the last processing run.
* @return list<string>
*/
public function getWarnings(): array public function getWarnings(): array
{ {
$res = []; $res = [];
if (isset($this->context)) {
foreach ($this->context->warnings as $message) { foreach ($this->context->warnings as $message) {
$res[] = $message->toString(); $res[] = $message->toString();
} }
}
return $res; return $res;
} }
private function throwsErrors(): void private function throwErrors(): void
{ {
if ($this->context->errors) { if ($this->context->errors) {
throw new ValidationException(null, $this->context->errors); throw new ValidationException(null, $this->context->errors);

View file

@ -8,27 +8,31 @@
namespace Nette\Schema; namespace Nette\Schema;
/**
* Defines the contract for schema elements used in data validation and normalization.
*/
interface Schema interface Schema
{ {
/** /**
* Normalization. * Applies pre-processing transformations to the raw input value (e.g., via before() hooks).
* @return mixed * @return mixed
*/ */
function normalize(mixed $value, Context $context); function normalize(mixed $value, Context $context);
/** /**
* Merging. * Merges two normalized values, with $value taking priority over $base.
* @return mixed * @return mixed
*/ */
function merge(mixed $value, mixed $base); function merge(mixed $value, mixed $base);
/** /**
* Validation and finalization. * Validates the value and applies defaults, transforms, and assertions.
* @return mixed * @return mixed
*/ */
function complete(mixed $value, Context $context); function complete(mixed $value, Context $context);
/** /**
* Returns the default value, or adds a missing-item error if the field is required.
* @return mixed * @return mixed
*/ */
function completeDefault(Context $context); function completeDefault(Context $context);

View file

@ -18,13 +18,16 @@ class ValidationException extends Nette\InvalidStateException
public function __construct( public function __construct(
?string $message, ?string $message,
/** @var list<Message> */ /** @var list<Message> */
private array $messages = [], private readonly array $messages = [],
) { ) {
parent::__construct($message ?? $messages[0]->toString()); parent::__construct($message ?? $messages[0]->toString());
} }
/** @return list<string> */ /**
* Returns all validation error messages as formatted strings.
* @return list<string>
*/
public function getMessages(): array public function getMessages(): array
{ {
$res = []; $res = [];
@ -36,7 +39,10 @@ class ValidationException extends Nette\InvalidStateException
} }
/** @return list<Message> */ /**
* Returns all validation error messages as Message objects.
* @return list<Message>
*/
public function getMessageObjects(): array public function getMessageObjects(): array
{ {
return $this->messages; return $this->messages;