@kirigami/php-prepros 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1114 -869
- package/index.d.ts +75 -2
- package/index.js +1 -1
- package/package.json +4 -4
- package/src/libraries/curl.class.php +5 -0
- package/src/libraries/html.class.php +23 -22
- package/src/libraries/img.class.php +356 -16
- package/src/libraries/md.class.php +476 -170
- package/src/libraries/md.plugins.php +19 -19
- package/src/libraries/normalizer.class.php +310 -0
- package/src/libraries/prepros.class.php +36 -7
- package/src/libraries/prepros.plugins.php +24 -0
- package/src/libraries/schema.class.php +550 -0
- package/src/libraries/scraper.class.php +64 -59
- package/src/libraries/str.class.php +49 -15
- package/src/libraries/yaml.class.php +66 -66
- package/src/phpjs/fstat.js +9 -0
- package/src/prepros.js +51 -44
- package/src/utils/getfilestats.js +30 -0
- package/src/utils/isbinary.js +14 -14
- package/src/utils.inc.php +9 -8
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
final class PREPROS
|
|
5
5
|
{
|
|
6
6
|
|
|
7
|
-
public static mixed $config;
|
|
8
|
-
|
|
7
|
+
public static mixed $config;
|
|
8
|
+
public static string $file = '';
|
|
9
|
+
private static string $root = '';
|
|
9
10
|
private static array $files = [];
|
|
10
11
|
private static array $hooks = [];
|
|
11
12
|
private static array $tags = [];
|
|
@@ -26,8 +27,12 @@ final class PREPROS
|
|
|
26
27
|
{
|
|
27
28
|
$dir = pathinfo($file, PATHINFO_DIRNAME) . S;
|
|
28
29
|
$target = $dir . ltrim(pathinfo($file, PATHINFO_FILENAME), '_') . '.html';
|
|
29
|
-
|
|
30
|
+
if(!$file = realpath($file)) return false;
|
|
31
|
+
self::$file = $file;
|
|
32
|
+
|
|
33
|
+
// to review = add the baseurl path
|
|
30
34
|
$absurl = str_replace('//', '/', str_replace('\\', '/', pathinfo(str_replace(realpath(self::$root), '', $file), PATHINFO_DIRNAME)) . '/');
|
|
35
|
+
|
|
31
36
|
$relroot = FS::getRelativePath($dir, self::$root);
|
|
32
37
|
$page = self::processHook('page_info', [$file, FS::phpFileInfo($file)]);
|
|
33
38
|
|
|
@@ -64,6 +69,7 @@ final class PREPROS
|
|
|
64
69
|
|
|
65
70
|
file_put_contents($target, $contents);
|
|
66
71
|
self::exportFile($target);
|
|
72
|
+
self::$file = '';
|
|
67
73
|
return realpath($target);
|
|
68
74
|
}
|
|
69
75
|
|
|
@@ -100,7 +106,12 @@ final class PREPROS
|
|
|
100
106
|
|
|
101
107
|
$dest = self::$root . 'sitemap.xml';
|
|
102
108
|
file_put_contents($dest, $dom->saveXML());
|
|
103
|
-
|
|
109
|
+
|
|
110
|
+
$destrobots = self::$root . 'robots.txt';
|
|
111
|
+
$urlrobots = rtrim(self::$config->data->baseurl, '/') . '/sitemap.xml';
|
|
112
|
+
file_put_contents($destrobots, "User-agent: *\nAllow: /\nSitemap: {$urlrobots}");
|
|
113
|
+
|
|
114
|
+
self::exportFile([$dest, $destrobots]);
|
|
104
115
|
return realpath($dest);
|
|
105
116
|
}
|
|
106
117
|
|
|
@@ -118,7 +129,7 @@ final class PREPROS
|
|
|
118
129
|
public static function getExportedFiles(): array
|
|
119
130
|
{
|
|
120
131
|
$files = array_unique(self::$files);
|
|
121
|
-
sort($files);
|
|
132
|
+
// sort($files);
|
|
122
133
|
return $files;
|
|
123
134
|
}
|
|
124
135
|
|
|
@@ -132,13 +143,31 @@ final class PREPROS
|
|
|
132
143
|
}
|
|
133
144
|
|
|
134
145
|
|
|
135
|
-
public static function mount(string|array $patterns)
|
|
146
|
+
public static function mount(string|array $patterns)
|
|
147
|
+
{
|
|
136
148
|
$results = self::callJS('mount', ['patterns' => $patterns]);
|
|
137
|
-
if(!$results->success) return false;
|
|
149
|
+
if (!$results->success) return false;
|
|
150
|
+
return $results->results;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
public static function fstat(string $path)
|
|
155
|
+
{
|
|
156
|
+
if (!$results = self::callJS('fstat', ['path' => $path])) return false;
|
|
157
|
+
if (!$results->success) return false;
|
|
158
|
+
if (!$results->results->exists) return false;
|
|
138
159
|
return $results->results;
|
|
139
160
|
}
|
|
140
161
|
|
|
141
162
|
|
|
163
|
+
public static function backtraceFile()
|
|
164
|
+
{
|
|
165
|
+
$debug = debug_backtrace();
|
|
166
|
+
if(!isset($debug[1])) return false;
|
|
167
|
+
else return $debug[1]['file'];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
142
171
|
public static function registerHook(string $hook, callable $clb)
|
|
143
172
|
{
|
|
144
173
|
self::$hooks[$hook][] = $clb;
|
|
@@ -7,6 +7,30 @@ PREPROS::registerTag('markdown', function ($tag, $attrs, $body) {
|
|
|
7
7
|
});
|
|
8
8
|
|
|
9
9
|
|
|
10
|
+
PREPROS::registerTag('img', function ($tag, $attrs, $body) {
|
|
11
|
+
if(empty($attrs['asset'])) return $tag;
|
|
12
|
+
$width = 0;
|
|
13
|
+
$height = 0;
|
|
14
|
+
$cover = false;
|
|
15
|
+
if(isset($attrs['width'])) {
|
|
16
|
+
$width = $attrs['width'] ?? 0;
|
|
17
|
+
unset($attrs['width']);
|
|
18
|
+
}
|
|
19
|
+
if(isset($attrs['height'])) {
|
|
20
|
+
$height = $attrs['height'] ?? 0;
|
|
21
|
+
unset($attrs['height']);
|
|
22
|
+
}
|
|
23
|
+
if(isset($attrs['cover'])) {
|
|
24
|
+
$cover = true;
|
|
25
|
+
unset($attrs['cover']);
|
|
26
|
+
}
|
|
27
|
+
$attrs['src'] = IMG::asset($attrs['asset'], $width, $height, $cover, PREPROS::$file);
|
|
28
|
+
unset($attrs['asset']);
|
|
29
|
+
foreach($attrs as $k => $v) $props[] = $k.'="'.$v.'"';
|
|
30
|
+
return '<img'.(!empty($props) ? ' '.join(' ', $props): '').'>';
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
|
|
10
34
|
PREPROS::registerHook('page_info', function($info) {
|
|
11
35
|
list($file, $page) = $info;
|
|
12
36
|
foreach($page as $k => $v) {
|
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* SchemaValidator
|
|
7
|
+
*
|
|
8
|
+
* A pure PHP JSON Schema validator (Draft 7 style, similar to Ajv),
|
|
9
|
+
* with no external dependencies.
|
|
10
|
+
*
|
|
11
|
+
* Usage example:
|
|
12
|
+
*
|
|
13
|
+
* $schema = [
|
|
14
|
+
* 'type' => 'object',
|
|
15
|
+
* 'required' => ['name', 'age'],
|
|
16
|
+
* 'properties' => [
|
|
17
|
+
* 'name' => ['type' => 'string', 'minLength' => 1],
|
|
18
|
+
* 'age' => ['type' => 'integer', 'minimum' => 0],
|
|
19
|
+
* 'tags' => ['type' => 'array', 'items' => ['type' => 'string']],
|
|
20
|
+
* ],
|
|
21
|
+
* 'additionalProperties' => false,
|
|
22
|
+
* ];
|
|
23
|
+
*
|
|
24
|
+
* $validator = new SchemaValidator($schema);
|
|
25
|
+
*
|
|
26
|
+
* if (!$validator->isValid(['name' => 'Alice', 'age' => 30])) {
|
|
27
|
+
* foreach ($validator->getErrors() as $error) {
|
|
28
|
+
* echo $error, PHP_EOL;
|
|
29
|
+
* }
|
|
30
|
+
* }
|
|
31
|
+
*/
|
|
32
|
+
final class SCHEMA
|
|
33
|
+
{
|
|
34
|
+
/** @var array<string,mixed> Schema used as the entry point of isValid(). */
|
|
35
|
+
private array $rootSchema;
|
|
36
|
+
|
|
37
|
+
/** @var array<string,mixed> Root document used to resolve local $ref pointers. */
|
|
38
|
+
private array $refRoot;
|
|
39
|
+
|
|
40
|
+
/** @var string[] */
|
|
41
|
+
private array $errors = [];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param array<string,mixed> $schema The root schema (JSON Schema format).
|
|
45
|
+
*/
|
|
46
|
+
public function __construct(array $schema)
|
|
47
|
+
{
|
|
48
|
+
$this->rootSchema = $schema;
|
|
49
|
+
$this->refRoot = $schema;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Validates data. Returns true/false, like Ajv's `validate()`.
|
|
54
|
+
* Errors are available via getErrors().
|
|
55
|
+
*
|
|
56
|
+
* @param mixed $data
|
|
57
|
+
*/
|
|
58
|
+
public function isValid(mixed $data): bool
|
|
59
|
+
{
|
|
60
|
+
$this->errors = [];
|
|
61
|
+
$this->validateAgainst($data, $this->rootSchema, '');
|
|
62
|
+
|
|
63
|
+
return count($this->errors) === 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Alias closer to Ajv's semantics (validate($data)).
|
|
68
|
+
*/
|
|
69
|
+
public function validate(mixed $data): bool
|
|
70
|
+
{
|
|
71
|
+
return $this->isValid($data);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @return string[] List of error messages (path: message).
|
|
76
|
+
*/
|
|
77
|
+
public function getErrors(): array
|
|
78
|
+
{
|
|
79
|
+
return $this->errors;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private function addError(string $path, string $message): void
|
|
83
|
+
{
|
|
84
|
+
$p = $path === '' ? '(root)' : $path;
|
|
85
|
+
$this->errors[] = "{$p}: {$message}";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* @param mixed $data
|
|
90
|
+
* @param array<string,mixed> $schema
|
|
91
|
+
*/
|
|
92
|
+
private function validateAgainst(mixed $data, array $schema, string $path): void
|
|
93
|
+
{
|
|
94
|
+
// Local $ref (#/definitions/xxx or #/$defs/xxx)
|
|
95
|
+
if (isset($schema['$ref'])) {
|
|
96
|
+
$resolved = $this->resolveRef((string) $schema['$ref']);
|
|
97
|
+
if ($resolved === null) {
|
|
98
|
+
$this->addError($path, "reference not found: {$schema['$ref']}");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
$this->validateAgainst($data, $resolved, $path);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (isset($schema['const'])) {
|
|
106
|
+
if (!$this->looseEquals($data, $schema['const'])) {
|
|
107
|
+
$this->addError($path, 'does not match the expected const value');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (isset($schema['enum']) && is_array($schema['enum'])) {
|
|
112
|
+
$found = false;
|
|
113
|
+
foreach ($schema['enum'] as $candidate) {
|
|
114
|
+
if ($this->looseEquals($data, $candidate)) {
|
|
115
|
+
$found = true;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (!$found) {
|
|
120
|
+
$this->addError($path, 'is not one of the allowed enum values');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (isset($schema['type'])) {
|
|
125
|
+
$this->validateType($data, $schema['type'], $path);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
$this->validateCombinators($data, $schema, $path);
|
|
129
|
+
|
|
130
|
+
$type = $this->detectType($data);
|
|
131
|
+
|
|
132
|
+
switch ($type) {
|
|
133
|
+
case 'string':
|
|
134
|
+
$this->validateString($data, $schema, $path);
|
|
135
|
+
break;
|
|
136
|
+
case 'integer':
|
|
137
|
+
case 'number':
|
|
138
|
+
$this->validateNumber($data, $schema, $path);
|
|
139
|
+
break;
|
|
140
|
+
case 'array':
|
|
141
|
+
$this->validateArray($data, $schema, $path);
|
|
142
|
+
break;
|
|
143
|
+
case 'object':
|
|
144
|
+
$this->validateObject($data, $schema, $path);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* @param array<string,mixed> $schema
|
|
151
|
+
*/
|
|
152
|
+
private function validateCombinators(mixed $data, array $schema, string $path): void
|
|
153
|
+
{
|
|
154
|
+
if (isset($schema['allOf']) && is_array($schema['allOf'])) {
|
|
155
|
+
foreach ($schema['allOf'] as $sub) {
|
|
156
|
+
$this->validateAgainst($data, $sub, $path);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (isset($schema['anyOf']) && is_array($schema['anyOf'])) {
|
|
161
|
+
$ok = false;
|
|
162
|
+
foreach ($schema['anyOf'] as $sub) {
|
|
163
|
+
if ($this->isSubSchemaValid($data, $sub)) {
|
|
164
|
+
$ok = true;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (!$ok) {
|
|
169
|
+
$this->addError($path, 'does not satisfy any of the anyOf schemas');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (isset($schema['oneOf']) && is_array($schema['oneOf'])) {
|
|
174
|
+
$matches = 0;
|
|
175
|
+
foreach ($schema['oneOf'] as $sub) {
|
|
176
|
+
if ($this->isSubSchemaValid($data, $sub)) {
|
|
177
|
+
$matches++;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if ($matches !== 1) {
|
|
181
|
+
$this->addError($path, "must satisfy exactly one oneOf schema ({$matches} match(es))");
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (isset($schema['not'])) {
|
|
186
|
+
if ($this->isSubSchemaValid($data, $schema['not'])) {
|
|
187
|
+
$this->addError($path, 'must not satisfy the "not" schema');
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @param array<string,mixed> $schema
|
|
194
|
+
*/
|
|
195
|
+
private function isSubSchemaValid(mixed $data, array $schema): bool
|
|
196
|
+
{
|
|
197
|
+
$sub = new self($schema);
|
|
198
|
+
// $ref pointers inside the sub-schema must keep resolving against
|
|
199
|
+
// the original root document, not against the sub-schema itself.
|
|
200
|
+
$sub->refRoot = $this->refRoot;
|
|
201
|
+
return $sub->isValid($data);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private function validateType(mixed $data, mixed $expected, string $path): void
|
|
205
|
+
{
|
|
206
|
+
$expectedTypes = is_array($expected) ? $expected : [$expected];
|
|
207
|
+
$actual = $this->detectType($data);
|
|
208
|
+
|
|
209
|
+
foreach ($expectedTypes as $t) {
|
|
210
|
+
if ($t === 'integer' && $actual === 'number' && is_float($data) && floor($data) === $data) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if ($t === $actual) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if ($t === 'number' && $actual === 'integer') {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
$expectedStr = implode('|', $expectedTypes);
|
|
222
|
+
$this->addError($path, "invalid type: expected {$expectedStr}, got {$actual}");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private function detectType(mixed $data): string
|
|
226
|
+
{
|
|
227
|
+
return match (true) {
|
|
228
|
+
$data === null => 'null',
|
|
229
|
+
is_bool($data) => 'boolean',
|
|
230
|
+
is_int($data) => 'integer',
|
|
231
|
+
is_float($data) => (floor($data) === $data ? 'integer' : 'number'),
|
|
232
|
+
is_string($data) => 'string',
|
|
233
|
+
// A JSON array always decodes to a PHP list array, whether or not
|
|
234
|
+
// json_decode() was called with associative=true.
|
|
235
|
+
is_array($data) && $this->isList($data) => 'array',
|
|
236
|
+
// A JSON object decodes to either an associative array
|
|
237
|
+
// (json_decode(..., true)) or a stdClass (json_decode(..., false)).
|
|
238
|
+
is_array($data) || is_object($data) => 'object',
|
|
239
|
+
default => 'unknown',
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* True for plain PHP list arrays (sequential integer keys from 0).
|
|
245
|
+
* Objects (stdClass or associative arrays) are never lists.
|
|
246
|
+
*/
|
|
247
|
+
private function isList(mixed $arr): bool
|
|
248
|
+
{
|
|
249
|
+
if (!is_array($arr)) {
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
if ($arr === []) {
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
return array_keys($arr) === range(0, count($arr) - 1);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* True when $data behaves like a JSON object: either a stdClass
|
|
260
|
+
* (json_decode(..., false)) or a non-list associative array
|
|
261
|
+
* (json_decode(..., true)).
|
|
262
|
+
*/
|
|
263
|
+
private function isObjectLike(mixed $data): bool
|
|
264
|
+
{
|
|
265
|
+
return is_object($data) || (is_array($data) && !$this->isList($data));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* @return string[] Property names of an object-like value, in order.
|
|
270
|
+
*/
|
|
271
|
+
private function objectKeys(mixed $data): array
|
|
272
|
+
{
|
|
273
|
+
if (is_object($data)) {
|
|
274
|
+
return array_keys(get_object_vars($data));
|
|
275
|
+
}
|
|
276
|
+
if (is_array($data)) {
|
|
277
|
+
return array_map('strval', array_keys($data));
|
|
278
|
+
}
|
|
279
|
+
return [];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private function objectHas(mixed $data, string $key): bool
|
|
283
|
+
{
|
|
284
|
+
if (is_object($data)) {
|
|
285
|
+
return property_exists($data, $key);
|
|
286
|
+
}
|
|
287
|
+
if (is_array($data)) {
|
|
288
|
+
return array_key_exists($key, $data);
|
|
289
|
+
}
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private function objectGet(mixed $data, string $key): mixed
|
|
294
|
+
{
|
|
295
|
+
if (is_object($data)) {
|
|
296
|
+
return $data->{$key};
|
|
297
|
+
}
|
|
298
|
+
return $data[$key];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* @param array<string,mixed> $schema
|
|
303
|
+
*/
|
|
304
|
+
private function validateString(mixed $data, array $schema, string $path): void
|
|
305
|
+
{
|
|
306
|
+
if (!is_string($data)) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
$len = function_exists('mb_strlen') ? mb_strlen($data) : strlen($data);
|
|
311
|
+
|
|
312
|
+
if (isset($schema['minLength']) && $len < (int) $schema['minLength']) {
|
|
313
|
+
$this->addError($path, "minimum length {$schema['minLength']} not met ({$len})");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (isset($schema['maxLength']) && $len > (int) $schema['maxLength']) {
|
|
317
|
+
$this->addError($path, "maximum length {$schema['maxLength']} exceeded ({$len})");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (isset($schema['pattern'])) {
|
|
321
|
+
$pattern = '/' . str_replace('/', '\/', (string) $schema['pattern']) . '/u';
|
|
322
|
+
if (@preg_match($pattern, $data) !== 1) {
|
|
323
|
+
$this->addError($path, "does not match pattern {$schema['pattern']}");
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (isset($schema['format'])) {
|
|
328
|
+
$this->validateFormat($data, (string) $schema['format'], $path);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private function validateFormat(string $data, string $format, string $path): void
|
|
333
|
+
{
|
|
334
|
+
$ok = match ($format) {
|
|
335
|
+
'email' => (bool) filter_var($data, FILTER_VALIDATE_EMAIL),
|
|
336
|
+
'uri', 'url' => (bool) filter_var($data, FILTER_VALIDATE_URL),
|
|
337
|
+
'date' => (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $data)
|
|
338
|
+
&& checkdate((int) substr($data, 5, 2), (int) substr($data, 8, 2), (int) substr($data, 0, 4)),
|
|
339
|
+
'date-time' => (bool) preg_match(
|
|
340
|
+
'/^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/',
|
|
341
|
+
$data
|
|
342
|
+
),
|
|
343
|
+
'ipv4' => (bool) filter_var($data, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4),
|
|
344
|
+
'ipv6' => (bool) filter_var($data, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6),
|
|
345
|
+
'uuid' => (bool) preg_match(
|
|
346
|
+
'/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i',
|
|
347
|
+
$data
|
|
348
|
+
),
|
|
349
|
+
default => true, // Unknown format: does not block, same as Ajv in non-strict mode.
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
if (!$ok) {
|
|
353
|
+
$this->addError($path, "invalid format, expected '{$format}'");
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* @param array<string,mixed> $schema
|
|
359
|
+
*/
|
|
360
|
+
private function validateNumber(mixed $data, array $schema, string $path): void
|
|
361
|
+
{
|
|
362
|
+
if (!is_int($data) && !is_float($data)) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (isset($schema['minimum']) && $data < $schema['minimum']) {
|
|
367
|
+
$this->addError($path, "must be >= {$schema['minimum']}");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (isset($schema['maximum']) && $data > $schema['maximum']) {
|
|
371
|
+
$this->addError($path, "must be <= {$schema['maximum']}");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (isset($schema['exclusiveMinimum']) && $data <= $schema['exclusiveMinimum']) {
|
|
375
|
+
$this->addError($path, "must be > {$schema['exclusiveMinimum']}");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (isset($schema['exclusiveMaximum']) && $data >= $schema['exclusiveMaximum']) {
|
|
379
|
+
$this->addError($path, "must be < {$schema['exclusiveMaximum']}");
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (isset($schema['multipleOf'])) {
|
|
383
|
+
$factor = (float) $schema['multipleOf'];
|
|
384
|
+
if ($factor > 0) {
|
|
385
|
+
$div = $data / $factor;
|
|
386
|
+
if (abs($div - round($div)) > 1e-9) {
|
|
387
|
+
$this->addError($path, "must be a multiple of {$schema['multipleOf']}");
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* @param array<string,mixed> $schema
|
|
395
|
+
*/
|
|
396
|
+
private function validateArray(mixed $data, array $schema, string $path): void
|
|
397
|
+
{
|
|
398
|
+
if (!is_array($data) || !$this->isList($data)) {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
$count = count($data);
|
|
403
|
+
|
|
404
|
+
if (isset($schema['minItems']) && $count < (int) $schema['minItems']) {
|
|
405
|
+
$this->addError($path, "must contain at least {$schema['minItems']} item(s)");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (isset($schema['maxItems']) && $count > (int) $schema['maxItems']) {
|
|
409
|
+
$this->addError($path, "must contain at most {$schema['maxItems']} item(s)");
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (($schema['uniqueItems'] ?? false) === true) {
|
|
413
|
+
$seen = [];
|
|
414
|
+
foreach ($data as $item) {
|
|
415
|
+
$key = json_encode($item, JSON_THROW_ON_ERROR);
|
|
416
|
+
if (isset($seen[$key])) {
|
|
417
|
+
$this->addError($path, 'items must be unique');
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
$seen[$key] = true;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (isset($schema['items'])) {
|
|
425
|
+
$items = $schema['items'];
|
|
426
|
+
|
|
427
|
+
if ($this->isList($items) && array_is_list($items)) {
|
|
428
|
+
// Tuple mode: items is a list of positional schemas.
|
|
429
|
+
foreach ($data as $i => $item) {
|
|
430
|
+
if (isset($items[$i])) {
|
|
431
|
+
$this->validateAgainst($item, $items[$i], "{$path}[{$i}]");
|
|
432
|
+
} elseif (isset($schema['additionalItems']) && $schema['additionalItems'] === false) {
|
|
433
|
+
$this->addError("{$path}[{$i}]", 'additional item not allowed');
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
} else {
|
|
437
|
+
// Standard mode: a single schema applied to every item.
|
|
438
|
+
foreach ($data as $i => $item) {
|
|
439
|
+
$this->validateAgainst($item, $items, "{$path}[{$i}]");
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* @param array<string,mixed> $schema
|
|
447
|
+
*/
|
|
448
|
+
private function validateObject(mixed $data, array $schema, string $path): void
|
|
449
|
+
{
|
|
450
|
+
// Accepts a JSON object regardless of how it was decoded:
|
|
451
|
+
// associative array (json_decode(..., true)) or stdClass (json_decode(..., false)).
|
|
452
|
+
if (!$this->isObjectLike($data)) {
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
$keys = $this->objectKeys($data);
|
|
457
|
+
|
|
458
|
+
// required
|
|
459
|
+
if (isset($schema['required']) && is_array($schema['required'])) {
|
|
460
|
+
foreach ($schema['required'] as $requiredKey) {
|
|
461
|
+
if (!$this->objectHas($data, (string) $requiredKey)) {
|
|
462
|
+
$this->addError($path, "missing required property: '{$requiredKey}'");
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
$properties = $schema['properties'] ?? [];
|
|
468
|
+
$patternProperties = $schema['patternProperties'] ?? [];
|
|
469
|
+
$additional = $schema['additionalProperties'] ?? true;
|
|
470
|
+
|
|
471
|
+
foreach ($keys as $key) {
|
|
472
|
+
$value = $this->objectGet($data, $key);
|
|
473
|
+
$childPath = $path === '' ? $key : "{$path}.{$key}";
|
|
474
|
+
$matched = false;
|
|
475
|
+
|
|
476
|
+
if (isset($properties[$key])) {
|
|
477
|
+
$matched = true;
|
|
478
|
+
$this->validateAgainst($value, $properties[$key], $childPath);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
foreach ($patternProperties as $pattern => $subSchema) {
|
|
482
|
+
$regex = '/' . str_replace('/', '\/', (string) $pattern) . '/u';
|
|
483
|
+
if (@preg_match($regex, $key) === 1) {
|
|
484
|
+
$matched = true;
|
|
485
|
+
$this->validateAgainst($value, $subSchema, $childPath);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (!$matched) {
|
|
490
|
+
if ($additional === false) {
|
|
491
|
+
$this->addError($childPath, 'additional property not allowed');
|
|
492
|
+
} elseif (is_array($additional)) {
|
|
493
|
+
$this->validateAgainst($value, $additional, $childPath);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
$count = count($keys);
|
|
499
|
+
|
|
500
|
+
if (isset($schema['minProperties']) && $count < (int) $schema['minProperties']) {
|
|
501
|
+
$this->addError($path, "must have at least {$schema['minProperties']} propert(y/ies)");
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (isset($schema['maxProperties']) && $count > (int) $schema['maxProperties']) {
|
|
505
|
+
$this->addError($path, "must have at most {$schema['maxProperties']} propert(y/ies)");
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Resolves a local reference such as "#/definitions/foo" or "#/$defs/foo".
|
|
511
|
+
*
|
|
512
|
+
* @return array<string,mixed>|null
|
|
513
|
+
*/
|
|
514
|
+
private function resolveRef(string $ref): ?array
|
|
515
|
+
{
|
|
516
|
+
if (!str_starts_with($ref, '#/')) {
|
|
517
|
+
return null; // External references are not supported.
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
$segments = explode('/', substr($ref, 2));
|
|
521
|
+
$current = $this->refRoot;
|
|
522
|
+
|
|
523
|
+
foreach ($segments as $segment) {
|
|
524
|
+
$segment = str_replace(['~1', '~0'], ['/', '~'], $segment);
|
|
525
|
+
if (!is_array($current) || !array_key_exists($segment, $current)) {
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
$current = $current[$segment];
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return is_array($current) ? $current : null;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private function looseEquals(mixed $a, mixed $b): bool
|
|
535
|
+
{
|
|
536
|
+
$aStructured = is_array($a) || is_object($a);
|
|
537
|
+
$bStructured = is_array($b) || is_object($b);
|
|
538
|
+
|
|
539
|
+
if ($aStructured && $bStructured) {
|
|
540
|
+
try {
|
|
541
|
+
// Compares structural (JSON) equality, so an associative array
|
|
542
|
+
// and a stdClass with the same content are considered equal.
|
|
543
|
+
return json_encode($a, JSON_THROW_ON_ERROR) === json_encode($b, JSON_THROW_ON_ERROR);
|
|
544
|
+
} catch (\JsonException) {
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return $a === $b;
|
|
549
|
+
}
|
|
550
|
+
}
|