Upgrade to 4.0.0

This commit is contained in:
Bastian Allgeier
2023-11-28 09:33:56 +01:00
parent f96b96af76
commit 3b0b6546ca
480 changed files with 21371 additions and 13327 deletions

View File

@@ -37,7 +37,7 @@ class A
*
* @param mixed ...$args Parameters to pass to the closures
*/
public static function apply(array $array, ...$args): array
public static function apply(array $array, mixed ...$args): array
{
array_walk_recursive($array, function (&$item) use ($args) {
if ($item instanceof Closure) {
@@ -49,16 +49,52 @@ class A
}
/**
* Counts the number of elements in an array
* Returns the average value of an array
*
* @param array $array
* @return int
* @param array $array The source array
* @param int $decimals The number of decimals to return
* @return float|null The average value
*/
public static function average(array $array, int $decimals = 0): float|null
{
if (empty($array) === true) {
return null;
}
return round((array_sum($array) / sizeof($array)), $decimals);
}
/**
* Counts the number of elements in an array
*/
public static function count(array $array): int
{
return count($array);
}
/**
* Merges arrays recursively
*
* <code>
* $defaults = [
* 'username' => 'admin',
* 'password' => 'admin',
* ];
*
* $options = A::extend($defaults, ['password' => 'super-secret']);
* // returns: [
* // 'username' => 'admin',
* // 'password' => 'super-secret'
* // ];
* </code>
*
* @psalm-suppress NamedArgumentNotAllowed
*/
public static function extend(array ...$arrays): array
{
return array_merge_recursive(...$arrays);
}
/**
* Checks if every element in the array passes the test
*
@@ -75,9 +111,7 @@ class A
* </code>
*
* @since 3.9.8
* @param array $array
* @param callable(mixed $value, int|string $key, array $array):bool $test
* @return bool
*/
public static function every(array $array, callable $test): bool
{
@@ -90,6 +124,57 @@ class A
return true;
}
/**
* Fills an array up with additional elements to certain amount.
*
* <code>
* $array = [
* 'cat' => 'miao',
* 'dog' => 'wuff',
* 'bird' => 'tweet'
* ];
*
* $result = A::fill($array, 5, 'elephant');
*
* // result: [
* // 'cat',
* // 'dog',
* // 'bird',
* // 'elephant',
* // 'elephant',
* // ];
* </code>
*
* @param array $array The source array
* @param int $limit The number of elements the array should
* contain after filling it up.
* @param mixed $fill The element, which should be used to
* fill the array. If it's a callable, it
* will be called with the current index
* @return array The filled-up result array
*/
public static function fill(
array $array,
int $limit,
mixed $fill = 'placeholder'
): array {
for ($x = count($array); $x < $limit; $x++) {
$array[] = is_callable($fill) ? $fill($x) : $fill;
}
return $array;
}
/**
* Filter the array using the given callback
* using both value and key
* @since 3.6.5
*/
public static function filter(array $array, callable $callback): array
{
return array_filter($array, $callback, ARRAY_FILTER_USE_BOTH);
}
/**
* Finds the first element matching the given callback
*
@@ -115,9 +200,7 @@ class A
* </code>
*
* @since 3.9.8
* @param array $array
* @param callable(mixed $value, int|string $key, array $array):bool $callback
* @return mixed
*/
public static function find(array $array, callable $callback): mixed
{
@@ -130,6 +213,28 @@ class A
return null;
}
/**
* Returns the first element of an array
*
* <code>
* $array = [
* 'cat' => 'miao',
* 'dog' => 'wuff',
* 'bird' => 'tweet'
* ];
*
* $first = A::first($array);
* // first: 'miao'
* </code>
*
* @param array $array The source array
* @return mixed The first element
*/
public static function first(array $array): mixed
{
return array_shift($array);
}
/**
* Gets an element of an array by key
*
@@ -159,7 +264,7 @@ class A
public static function get(
$array,
string|int|array|null $key,
$default = null
mixed $default = null
) {
if (is_array($array) === false) {
return $array;
@@ -237,22 +342,45 @@ class A
/**
* Checks if array has a value
*
* @param array $array
* @param mixed $value
* @param bool $strict
* @return bool
*/
public static function has(array $array, $value, bool $strict = false): bool
{
public static function has(
array $array,
mixed $value,
bool $strict = false
): bool {
return in_array($value, $array, $strict);
}
/**
* Checks whether an array is associative or not
*
* <code>
* $array = ['a', 'b', 'c'];
*
* A::isAssociative($array);
* // returns: false
*
* $array = ['a' => 'a', 'b' => 'b', 'c' => 'c'];
*
* A::isAssociative($array);
* // returns: true
* </code>
*
* @param array $array The array to analyze
* @return bool true: The array is associative false: It's not
*/
public static function isAssociative(array $array): bool
{
return ctype_digit(implode('', array_keys($array))) === false;
}
/**
* Joins the elements of an array to a string
*/
public static function join(array|string $value, string $separator = ', '): string
{
public static function join(
array|string $value,
string $separator = ', '
): string {
if (is_string($value) === true) {
return $value;
}
@@ -271,10 +399,6 @@ class A
*
* // Now you can access the array by the id
* </code>
*
* @param array $array
* @param string|callable $keyBy
* @return array
*/
public static function keyBy(array $array, string|callable $keyBy): array
{
@@ -290,6 +414,38 @@ class A
return array_combine($keys, $array);
}
/**
* Returns the last element of an array
*
* <code>
* $array = [
* 'cat' => 'miao',
* 'dog' => 'wuff',
* 'bird' => 'tweet'
* ];
*
* $last = A::last($array);
* // last: 'tweet'
* </code>
*
* @param array $array The source array
* @return mixed The last element
*/
public static function last(array $array): mixed
{
return array_pop($array);
}
/**
* A simple wrapper around array_map
* with a sane argument order
* @since 3.6.0
*/
public static function map(array $array, callable $map): array
{
return array_map($map, $array);
}
public const MERGE_OVERWRITE = 0;
public const MERGE_APPEND = 1;
public const MERGE_REPLACE = 2;
@@ -412,246 +568,13 @@ class A
/**
* Reduce an array to a single value
*
* @param array $array
* @param callable $callback
* @param mixed $initial
* @return mixed
*/
public static function reduce(array $array, callable $callback, $initial = null): mixed
{
return array_reduce($array, $callback, $initial);
}
/**
* Shuffles an array and keeps the keys
*
* <code>
* $array = [
* 'cat' => 'miao',
* 'dog' => 'wuff',
* 'bird' => 'tweet'
* ];
*
* $shuffled = A::shuffle($array);
* // output: [
* // 'dog' => 'wuff',
* // 'cat' => 'miao',
* // 'bird' => 'tweet'
* // ];
* </code>
*
* @param array $array The source array
* @return array The shuffled result array
*/
public static function shuffle(array $array): array
{
$keys = array_keys($array);
$new = [];
shuffle($keys);
// resort the array
foreach ($keys as $key) {
$new[$key] = $array[$key];
}
return $new;
}
/**
* Returns a slice of an array
*
* @param array $array
* @param int $offset
* @param int|null $length
* @param bool $preserveKeys
* @return array
*/
public static function slice(
public static function reduce(
array $array,
int $offset,
int $length = null,
bool $preserveKeys = false
): array {
return array_slice($array, $offset, $length, $preserveKeys);
}
/**
* Checks if at least one element in the array passes the test
*
* <code>
* $array = [1, 30, 39, 29, 10, 'foo' => 12, 13];
*
* $isAboveThreshold = fn($value) => $value > 30;
* echo A::some($array, $isAboveThreshold) ? 'true' : 'false';
* // output: 'true'
*
* $isStringKey = fn($value, $key) => is_string($key);
* echo A::some($array, $isStringKey) ? 'true' : 'false';
* // output: 'true'
* </code>
*
* @since 3.9.8
* @param array $array
* @param callable(mixed $value, int|string $key, array $array):bool $test
* @return bool
*/
public static function some(array $array, callable $test): bool
{
foreach ($array as $key => $value) {
if ($test($value, $key, $array)) {
return true;
}
}
return false;
}
/**
* Sums an array
*
* @param array $array
* @return int|float
*/
public static function sum(array $array): int|float
{
return array_sum($array);
}
/**
* Returns the first element of an array
*
* <code>
* $array = [
* 'cat' => 'miao',
* 'dog' => 'wuff',
* 'bird' => 'tweet'
* ];
*
* $first = A::first($array);
* // first: 'miao'
* </code>
*
* @param array $array The source array
* @return mixed The first element
*/
public static function first(array $array)
{
return array_shift($array);
}
/**
* Returns the last element of an array
*
* <code>
* $array = [
* 'cat' => 'miao',
* 'dog' => 'wuff',
* 'bird' => 'tweet'
* ];
*
* $last = A::last($array);
* // last: 'tweet'
* </code>
*
* @param array $array The source array
* @return mixed The last element
*/
public static function last(array $array)
{
return array_pop($array);
}
/**
* Returns a number of random elements from an array,
* either in original or shuffled order
*/
public static function random(array $array, int $count = 1, bool $shuffle = false): array
{
if ($shuffle) {
return array_slice(self::shuffle($array), 0, $count);
}
if ($count === 1) {
$key = array_rand($array);
return [$key => $array[$key]];
}
return self::get($array, array_rand($array, $count));
}
/**
* Fills an array up with additional elements to certain amount.
*
* <code>
* $array = [
* 'cat' => 'miao',
* 'dog' => 'wuff',
* 'bird' => 'tweet'
* ];
*
* $result = A::fill($array, 5, 'elephant');
*
* // result: [
* // 'cat',
* // 'dog',
* // 'bird',
* // 'elephant',
* // 'elephant',
* // ];
* </code>
*
* @param array $array The source array
* @param int $limit The number of elements the array should
* contain after filling it up.
* @param mixed $fill The element, which should be used to
* fill the array. If it's a callable, it
* will be called with the current index
* @return array The filled-up result array
*/
public static function fill(array $array, int $limit, $fill = 'placeholder'): array
{
for ($x = count($array); $x < $limit; $x++) {
$array[] = is_callable($fill) ? $fill($x) : $fill;
}
return $array;
}
/**
* A simple wrapper around array_map
* with a sane argument order
* @since 3.6.0
*/
public static function map(array $array, callable $map): array
{
return array_map($map, $array);
}
/**
* Move an array item to a new index
*/
public static function move(array $array, int $from, int $to): array
{
$total = count($array);
if ($from >= $total || $from < 0) {
throw new Exception('Invalid "from" index');
}
if ($to >= $total || $to < 0) {
throw new Exception('Invalid "to" index');
}
// remove the item from the array
$item = array_splice($array, $from, 1);
// inject it at the new position
array_splice($array, $to, 0, $item);
return $array;
callable $callback,
$initial = null
): mixed {
return array_reduce($array, $callback, $initial);
}
/**
@@ -685,6 +608,31 @@ class A
return array_values(array_diff($required, array_keys($array)));
}
/**
* Move an array item to a new index
*/
public static function move(array $array, int $from, int $to): array
{
$total = count($array);
if ($from >= $total || $from < 0) {
throw new Exception('Invalid "from" index');
}
if ($to >= $total || $to < 0) {
throw new Exception('Invalid "to" index');
}
// remove the item from the array
$item = array_splice($array, $from, 1);
// inject it at the new position
array_splice($array, $to, 0, $item);
return $array;
}
/**
* Normalizes an array into a nested form by converting
* dot notation in keys to nested structures
@@ -704,7 +652,7 @@ class A
foreach ($array as $fullKey => $value) {
// extract the first part of a multi-level key, keep the others
$subKeys = explode('.', $fullKey);
$subKeys = is_int($fullKey) ? [$fullKey] : explode('.', $fullKey);
$key = array_shift($subKeys);
// skip the magic for ignored keys
@@ -762,6 +710,105 @@ class A
];
}
/**
* Returns a number of random elements from an array,
* either in original or shuffled order
*/
public static function random(
array $array,
int $count = 1,
bool $shuffle = false
): array {
if ($shuffle === true) {
return array_slice(self::shuffle($array), 0, $count);
}
if ($count === 1) {
$key = array_rand($array);
return [$key => $array[$key]];
}
return self::get($array, array_rand($array, $count));
}
/**
* Shuffles an array and keeps the keys
*
* <code>
* $array = [
* 'cat' => 'miao',
* 'dog' => 'wuff',
* 'bird' => 'tweet'
* ];
*
* $shuffled = A::shuffle($array);
* // output: [
* // 'dog' => 'wuff',
* // 'cat' => 'miao',
* // 'bird' => 'tweet'
* // ];
* </code>
*
* @param array $array The source array
* @return array The shuffled result array
*/
public static function shuffle(array $array): array
{
$keys = array_keys($array);
$new = [];
shuffle($keys);
// resort the array
foreach ($keys as $key) {
$new[$key] = $array[$key];
}
return $new;
}
/**
* Returns a slice of an array
*/
public static function slice(
array $array,
int $offset,
int|null $length = null,
bool $preserveKeys = false
): array {
return array_slice($array, $offset, $length, $preserveKeys);
}
/**
* Checks if at least one element in the array passes the test
*
* <code>
* $array = [1, 30, 39, 29, 10, 'foo' => 12, 13];
*
* $isAboveThreshold = fn($value) => $value > 30;
* echo A::some($array, $isAboveThreshold) ? 'true' : 'false';
* // output: 'true'
*
* $isStringKey = fn($value, $key) => is_string($key);
* echo A::some($array, $isStringKey) ? 'true' : 'false';
* // output: 'true'
* </code>
*
* @since 3.9.8
* @param callable(mixed $value, int|string $key, array $array):bool $test
*/
public static function some(array $array, callable $test): bool
{
foreach ($array as $key => $value) {
if ($test($value, $key, $array)) {
return true;
}
}
return false;
}
/**
* Sorts a multi-dimensional array by a certain column
*
@@ -815,7 +862,7 @@ class A
array $array,
string $field,
string $direction = 'desc',
$method = SORT_REGULAR
int $method = SORT_REGULAR
): array {
$direction = strtolower($direction) === 'desc' ? SORT_DESC : SORT_ASC;
$helper = [];
@@ -842,63 +889,11 @@ class A
}
/**
* Checks whether an array is associative or not
*
* <code>
* $array = ['a', 'b', 'c'];
*
* A::isAssociative($array);
* // returns: false
*
* $array = ['a' => 'a', 'b' => 'b', 'c' => 'c'];
*
* A::isAssociative($array);
* // returns: true
* </code>
*
* @param array $array The array to analyze
* @return bool true: The array is associative false: It's not
* Sums an array
*/
public static function isAssociative(array $array): bool
public static function sum(array $array): int|float
{
return ctype_digit(implode('', array_keys($array))) === false;
}
/**
* Returns the average value of an array
*
* @param array $array The source array
* @param int $decimals The number of decimals to return
* @return float The average value
*/
public static function average(array $array, int $decimals = 0): float|null
{
if (empty($array) === true) {
return null;
}
return round((array_sum($array) / sizeof($array)), $decimals);
}
/**
* Merges arrays recursively
*
* <code>
* $defaults = [
* 'username' => 'admin',
* 'password' => 'admin',
* ];
*
* $options = A::extend($defaults, ['password' => 'super-secret']);
* // returns: [
* // 'username' => 'admin',
* // 'password' => 'super-secret'
* // ];
* </code>
*/
public static function extend(array ...$arrays): array
{
return array_merge_recursive(...$arrays);
return array_sum($array);
}
/**
@@ -938,6 +933,22 @@ class A
return $array;
}
/**
* Remove key(s) from an array
* @since 3.6.5
*/
public static function without(array $array, int|string|array $keys): array
{
if (is_int($keys) === true || is_string($keys) === true) {
$keys = static::wrap($keys);
}
return static::filter(
$array,
fn ($value, $key) => in_array($key, $keys, true) === false
);
}
/**
* Wraps the given value in an array
* if it's not an array yet.
@@ -954,30 +965,4 @@ class A
return $array;
}
/**
* Filter the array using the given callback
* using both value and key
* @since 3.6.5
*/
public static function filter(array $array, callable $callback): array
{
return array_filter($array, $callback, ARRAY_FILTER_USE_BOTH);
}
/**
* Remove key(s) from an array
* @since 3.6.5
*/
public static function without(array $array, int|string|array $keys): array
{
if (is_int($keys) || is_string($keys)) {
$keys = static::wrap($keys);
}
return static::filter(
$array,
fn ($value, $key) => in_array($key, $keys, true) === false
);
}
}

View File

@@ -30,6 +30,9 @@ class Collection extends Iterator implements Countable
* Whether the collection keys should be
* treated as case-sensitive
*
* @todo 5.0 Check if case-sensitive can become the
* default mode, see https://github.com/getkirby/kirby/pull/5635
*
* @var bool
*/
protected $caseSensitive = false;
@@ -67,8 +70,7 @@ class Collection extends Iterator implements Countable
/**
* Improve var_dump() output
*
* @return array
* @codeCoverageIgnore
*/
public function __debugInfo(): array
{
@@ -521,21 +523,24 @@ class Collection extends Iterator implements Countable
* Groups the elements by a given field or callback function
*
* @param string|Closure $field
* @param bool $i
* @return \Kirby\Toolkit\Collection A new collection with an element for
* each group and a subcollection in
* each group
* @throws \Exception if $field is not a string nor a callback function
*/
public function group($field, bool $i = true)
public function group($field, bool $caseInsensitive = true)
{
// group by field name
if (is_string($field) === true) {
return $this->group(function ($item) use ($field, $i) {
return $this->group(function ($item) use ($field, $caseInsensitive) {
$value = $this->getAttribute($item, $field);
// ignore upper/lowercase for group names
return $i === true ? Str::lower($value) : (string)$value;
if ($caseInsensitive === true) {
return Str::lower($value);
}
return (string)$value;
});
}
@@ -740,14 +745,17 @@ class Collection extends Iterator implements Countable
* Add pagination
*
* @param array ...$arguments
* @return static a sliced set of data
* @return $this|static a sliced set of data
*/
public function paginate(...$arguments)
{
$this->pagination = Pagination::for($this, ...$arguments);
// slice and clone the collection according to the pagination
return $this->slice($this->pagination->offset(), $this->pagination->limit());
return $this->slice(
$this->pagination->offset(),
$this->pagination->limit()
);
}
/**

View File

@@ -27,80 +27,44 @@ class Component
{
/**
* Registry for all component mixins
*
* @var array
*/
public static $mixins = [];
public static array $mixins = [];
/**
* Registry for all component types
*
* @var array
*/
public static $types = [];
public static array $types = [];
/**
* An array of all passed attributes
*
* @var array
*/
protected $attrs = [];
protected array $attrs = [];
/**
* An array of all computed properties
*
* @var array
*/
protected $computed = [];
protected array $computed = [];
/**
* An array of all registered methods
*
* @var array
*/
protected $methods = [];
protected array $methods = [];
/**
* An array of all component options
* from the component definition
*
* @var array
*/
protected $options = [];
protected array|string $options = [];
/**
* An array of all resolved props
*
* @var array
*/
protected $props = [];
protected array $props = [];
/**
* The component type
*
* @var string
*/
protected $type;
/**
* Magic caller for defined methods and properties
*/
public function __call(string $name, array $arguments = [])
{
if (array_key_exists($name, $this->computed) === true) {
return $this->computed[$name];
}
if (array_key_exists($name, $this->props) === true) {
return $this->props[$name];
}
if (array_key_exists($name, $this->methods) === true) {
return $this->methods[$name]->call($this, ...$arguments);
}
return $this->$name;
}
protected string $type;
/**
* Creates a new component for the given type
@@ -133,8 +97,29 @@ class Component
$this->type = $type;
}
/**
* Magic caller for defined methods and properties
*/
public function __call(string $name, array $arguments = [])
{
if (array_key_exists($name, $this->computed) === true) {
return $this->computed[$name];
}
if (array_key_exists($name, $this->props) === true) {
return $this->props[$name];
}
if (array_key_exists($name, $this->methods) === true) {
return $this->methods[$name]->call($this, ...$arguments);
}
return $this->$name;
}
/**
* Improved `var_dump` output
* @codeCoverageIgnore
*/
public function __debugInfo(): array
{
@@ -167,24 +152,29 @@ class Component
*/
protected function applyProps(array $props): void
{
foreach ($props as $propName => $propFunction) {
if ($propFunction instanceof Closure) {
if (isset($this->attrs[$propName]) === true) {
foreach ($props as $name => $function) {
if ($function instanceof Closure) {
if (isset($this->attrs[$name]) === true) {
try {
$this->$propName = $this->props[$propName] = $propFunction->call($this, $this->attrs[$propName]);
$this->$name = $this->props[$name] = $function->call(
$this,
$this->attrs[$name]
);
continue;
} catch (TypeError) {
throw new TypeError('Invalid value for "' . $propName . '"');
}
} else {
try {
$this->$propName = $this->props[$propName] = $propFunction->call($this);
} catch (ArgumentCountError) {
throw new ArgumentCountError('Please provide a value for "' . $propName . '"');
throw new TypeError('Invalid value for "' . $name . '"');
}
}
} else {
$this->$propName = $this->props[$propName] = $propFunction;
try {
$this->$name = $this->props[$name] = $function->call($this);
continue;
} catch (ArgumentCountError) {
throw new ArgumentCountError('Please provide a value for "' . $name . '"');
}
}
$this->$name = $this->props[$name] = $function;
}
}
@@ -194,9 +184,9 @@ class Component
*/
protected function applyComputed(array $computed): void
{
foreach ($computed as $computedName => $computedFunction) {
if ($computedFunction instanceof Closure) {
$this->$computedName = $this->computed[$computedName] = $computedFunction->call($this);
foreach ($computed as $name => $function) {
if ($function instanceof Closure) {
$this->$name = $this->computed[$name] = $function->call($this);
}
}
}
@@ -214,7 +204,10 @@ class Component
throw new Exception('Component definition ' . $definition . ' does not exist');
}
static::$types[$type] = $definition = F::load($definition, allowOutput: false);
static::$types[$type] = $definition = F::load(
$definition,
allowOutput: false
);
}
return $definition;
@@ -244,20 +237,21 @@ class Component
}
// inject mixins
if (isset($options['mixins']) === true) {
foreach ($options['mixins'] as $mixin) {
if (isset(static::$mixins[$mixin]) === true) {
if (is_string(static::$mixins[$mixin]) === true) {
// resolve a path to a mixin on demand
foreach ($options['mixins'] ?? [] as $mixin) {
if (isset(static::$mixins[$mixin]) === true) {
if (is_string(static::$mixins[$mixin]) === true) {
// resolve a path to a mixin on demand
static::$mixins[$mixin] = F::load(static::$mixins[$mixin], allowOutput: false);
}
$options = array_replace_recursive(
static::$mixins[$mixin] = F::load(
static::$mixins[$mixin],
$options
allowOutput: false
);
}
$options = array_replace_recursive(
static::$mixins[$mixin],
$options
);
}
}
@@ -269,8 +263,10 @@ class Component
*/
public function toArray(): array
{
if (($this->options['toArray'] ?? null) instanceof Closure) {
return $this->options['toArray']->call($this);
$closure = $this->options['toArray'] ?? null;
if ($closure instanceof Closure) {
return $closure->call($this);
}
$array = array_merge($this->attrs, $this->props, $this->computed);

View File

@@ -14,8 +14,5 @@ namespace Kirby\Toolkit;
*/
class Config extends Silo
{
/**
* @var array
*/
public static $data = [];
public static array $data = [];
}

View File

@@ -49,7 +49,9 @@ class Controller
public function call($bind = null, $data = [])
{
// unwrap lazy values in arguments
$args = $this->arguments($data);
$args = LazyValue::unwrap($args);
if ($bind === null) {
return ($this->function)(...$args);

View File

@@ -7,6 +7,7 @@ use DateTime;
use DateTimeInterface;
use DateTimeZone;
use Exception;
use IntlDateFormatter;
use Kirby\Exception\InvalidArgumentException;
/**
@@ -119,6 +120,20 @@ class Date extends DateTime
return $this;
}
/**
* Formats the datetime value with a custom handler
* or with the globally configured one
*
* @param 'date'|'intl'|'strftime'|null $handler Custom date handler or `null`
* for the globally configured one
*/
public function formatWithHandler(
string|IntlDateFormatter|null $format = null,
string|null $handler = null
): string|int|false {
return Str::date($this->timestamp(), $format, $handler);
}
/**
* Gets or sets the hour value
*/
@@ -399,7 +414,6 @@ class Date extends DateTime
* @param array|string|int|null $input Full array with `size` and/or `unit` keys, `unit`
* string, `size` int or `null` for the default
* @param array|null $default Default values to use if one or both values are not provided
* @return array
*/
public static function stepConfig(
// no type hint to use InvalidArgumentException at the end

View File

@@ -169,6 +169,8 @@ class Dom
DOMAttr $attr,
array $options
): bool|string {
$options = static::normalizeSanitizeOptions($options);
$allowedTags = $options['allowedTags'];
// check if the attribute is in the list of global allowed attributes
@@ -217,6 +219,8 @@ class Dom
DOMAttr $attr,
array $options
): bool|string {
$options = static::normalizeSanitizeOptions($options);
$allowedAttrs = $options['allowedAttrs'];
if ($allowedAttrs === true) {
@@ -255,6 +259,8 @@ class Dom
string $url,
array $options
): bool|string {
$options = static::normalizeSanitizeOptions($options);
$url = Str::lower($url);
// allow empty URL values
@@ -274,7 +280,7 @@ class Dom
// allow site-internal URLs that didn't match the
// protocol-relative check above
if (mb_substr($url, 0, 1) === '/') {
if (mb_substr($url, 0, 1) === '/' && $options['allowHostRelativeUrls'] !== true) {
// if a CMS instance is active, only allow the URL
// if it doesn't point outside of the index URL
if ($kirby = App::instance(null, true)) {
@@ -423,6 +429,8 @@ class Dom
array $options,
Closure|null $compare = null
): string|false {
$options = static::normalizeSanitizeOptions($options);
$allowedNamespaces = $options['allowedNamespaces'];
$localName = $node->localName;
$compare ??= fn ($expected, $real): bool => $expected === $real;
@@ -517,6 +525,9 @@ class Dom
* or `true` for any
* - `allowedDomains`: Allowed hostnames for HTTP(S) URLs in `urlAttrs`
* and inside `url()` wrappers or `true` for any
* - `allowHostRelativeUrls`: Whether URLs that begin with `/` should be
* allowed even if the site index URL is in a subfolder (useful when using
* the HTML `<base>` element where the sanitized code will be rendered)
* - `allowedNamespaces`: Associative array of all allowed namespace URIs;
* the array keys are reference names that can be referred to from the
* `allowedAttrPrefixes`, `allowedAttrs`, `allowedTags`, `disallowedTags`
@@ -547,20 +558,7 @@ class Dom
*/
public function sanitize(array $options): array
{
$options = array_merge([
'allowedAttrPrefixes' => [],
'allowedAttrs' => true,
'allowedDataUris' => true,
'allowedDomains' => true,
'allowedNamespaces' => true,
'allowedPIs' => true,
'allowedTags' => true,
'attrCallback' => null,
'disallowedTags' => [],
'doctypeCallback' => null,
'elementCallback' => null,
'urlAttrs' => ['href', 'src', 'xlink:href'],
], $options);
$options = static::normalizeSanitizeOptions($options);
$errors = [];
@@ -701,6 +699,38 @@ class Dom
return trim($this->doc->saveXML());
}
/**
* Ensures that all options are set in the user-provided
* options array (otherwise setting the default option)
*/
protected static function normalizeSanitizeOptions(array $options): array
{
// increase performance for already normalized option arrays
if (($options['_normalized'] ?? false) === true) {
return $options;
}
$options = array_merge([
'allowedAttrPrefixes' => [],
'allowedAttrs' => true,
'allowedDataUris' => true,
'allowedDomains' => true,
'allowHostRelativeUrls' => true,
'allowedNamespaces' => true,
'allowedPIs' => true,
'allowedTags' => true,
'attrCallback' => null,
'disallowedTags' => [],
'doctypeCallback' => null,
'elementCallback' => null,
'urlAttrs' => ['href', 'src', 'xlink:href'],
], $options);
$options['_normalized'] = true;
return $options;
}
/**
* Sanitizes an attribute
*
@@ -841,14 +871,14 @@ class Dom
// custom check (if the attribute is still in the document)
if ($attr->ownerElement !== null && $options['attrCallback']) {
$errors = array_merge($errors, $options['attrCallback']($attr) ?? []);
$errors = array_merge($errors, $options['attrCallback']($attr, $options) ?? []);
}
}
}
// custom check
if ($options['elementCallback']) {
$errors = array_merge($errors, $options['elementCallback']($element) ?? []);
$errors = array_merge($errors, $options['elementCallback']($element, $options) ?? []);
}
}
@@ -898,7 +928,7 @@ class Dom
}
if ($options['doctypeCallback']) {
$options['doctypeCallback']($doctype);
$options['doctypeCallback']($doctype, $options);
}
}
}

View File

@@ -323,7 +323,7 @@ class Html extends Xml
{
$attr = array_merge([
'src' => $src,
'alt' => ' '
'alt' => ''
], $attr);
return static::tag('img', '', $attr);
@@ -597,7 +597,7 @@ class Html extends Xml
return false;
}
return preg_match('!^[a-zA-Z0-9_-]+$!', $id);
return preg_match('!^[a-zA-Z0-9_-]+$!', $id) === 1;
};
switch ($path->toString()) {

View File

@@ -18,62 +18,51 @@ class I18n
{
/**
* Custom loader function
*
* @var Closure
*/
public static $load = null;
public static Closure|null $load = null;
/**
* Current locale
*
* @var string|\Closure
*/
public static $locale = 'en';
public static string|Closure|null $locale = 'en';
/**
* All registered translations
*
* @var array
*/
public static $translations = [];
public static array $translations = [];
/**
* The fallback locale or a
* list of fallback locales
*
* @var string|array|\Closure|null
* The fallback locale or a list of fallback locales
*/
public static $fallback = ['en'];
public static string|array|Closure|null $fallback = ['en'];
/**
* Cache of `NumberFormatter` objects by locale
*
* @var array
*/
protected static $decimalsFormatters = [];
protected static array $decimalsFormatters = [];
/**
* Returns the list of fallback locales
*/
public static function fallbacks(): array
{
if (
is_array(static::$fallback) === true ||
is_string(static::$fallback) === true
) {
return A::wrap(static::$fallback);
if (is_callable(static::$fallback) === true) {
static::$fallback = (static::$fallback)();
}
if (is_callable(static::$fallback) === true) {
return static::$fallback = A::wrap((static::$fallback)());
if (is_array(static::$fallback) === true) {
return static::$fallback;
}
if (is_string(static::$fallback) === true) {
return A::wrap(static::$fallback);
}
return static::$fallback = ['en'];
}
/**
* Returns singular or plural
* depending on the given number
* Returns singular or plural depending on the given number
*
* @param bool $none If true, 'none' will be returned if the count is 0
*/
@@ -98,88 +87,24 @@ class I18n
}
/**
* Returns the locale code
* Returns the current locale code
*/
public static function locale(): string
{
if (is_string(static::$locale) === true) {
return static::$locale;
if (is_callable(static::$locale) === true) {
static::$locale = (static::$locale)();
}
if (is_callable(static::$locale) === true) {
return static::$locale = (static::$locale)();
if (is_string(static::$locale) === true) {
return static::$locale;
}
return static::$locale = 'en';
}
/**
* Translates a given message
* according to the currently set locale
*/
public static function translate(
string|array|null $key,
string|array $fallback = null,
string $locale = null
): string|array|Closure|null {
$locale ??= static::locale();
if (is_array($key) === true) {
// try to use actual locale
if ($result = $key[$locale] ?? null) {
return $result;
}
// try to use language code, e.g. `es` when locale is `es_ES`
if ($result = $key[Str::before($locale, '_')] ?? null) {
return $result;
}
// use global wildcard as i18n key
if (isset($key['*']) === true) {
return static::translate($key['*'], $key['*']);
}
// use fallback
if (is_array($fallback) === true) {
return
$fallback[$locale] ??
$fallback['en'] ??
reset($fallback);
}
return $fallback;
}
// $key is a string
if ($result = static::translation($locale)[$key] ?? null) {
return $result;
}
if ($fallback !== null) {
return $fallback;
}
foreach (static::fallbacks() as $fallback) {
// skip locales we have already tried
if ($locale === $fallback) {
continue;
}
if ($result = static::translation($fallback)[$key] ?? null) {
return $result;
}
}
return null;
}
/**
* Translate by key and then replace
* placeholders in the text
*
* @param string $key
* @param string|array|null $fallback
* @param array|null $replace
* @param string|null $locale
* @return string
*/
public static function template(
string $key,
@@ -194,11 +119,122 @@ class I18n
}
$template = static::translate($key, $fallback, $locale);
return Str::template($template, $replace, [
'fallback' => '-',
'start' => '{',
'end' => '}'
]);
return Str::template($template, $replace, ['fallback' => '-']);
}
/**
* Translates either a given i18n key from global translations
* or chooses correct entry from array of translations
* according to the currently set locale
*/
public static function translate(
string|array|null $key,
string|array $fallback = null,
string $locale = null
): string|array|Closure|null {
// use current locale if no specific is passed
$locale ??= static::locale();
// create shorter locale code, e.g. `es` for `es_ES` locale
$shortLocale = Str::before($locale, '_');
// There are two main use cases that we will treat separately:
// (1) with a string representing an i18n key to be looked up
// (2) an array with entries per locale
//
// Both with various ways of handling fallbacks, provided
// explicitly via the parameter and/or from global defaults.
// (1) string $key: look up i18n string from global translations
if (is_string($key) === true) {
// look up locale in global translations list,
if ($result = static::translation($locale)[$key] ?? null) {
return $result;
}
// prefer any direct provided $fallback
// over further fallback alternatives
if ($fallback !== null) {
if (is_array($fallback) === true) {
return static::translate($fallback, null, $locale);
}
return $fallback;
}
// last resort: try using the fallback locales
foreach (static::fallbacks() as $fallback) {
// skip locale if we have already tried to save performance
if ($locale === $fallback) {
continue;
}
if ($result = static::translation($fallback)[$key] ?? null) {
return $result;
}
}
return null;
}
// --------
// (2) array|null $key with entries per locale
// try entry for long and short locale
if ($result = $key[$locale] ?? null) {
return $result;
}
if ($result = $key[$shortLocale] ?? null) {
return $result;
}
// if the array as a global wildcard entry,
// use this one as i18n key and try to resolve
// this via part (1) of this method
if ($wildcard = $key['*'] ?? null) {
if ($result = static::translate($wildcard, $wildcard, $locale)) {
return $result;
}
}
// if the $fallback parameter is an array, we can assume
// that it's also an array with entries per locale:
// check with long and short locale if we find a matching entry
if ($result = $fallback[$locale] ?? null) {
return $result;
}
if ($result = $fallback[$shortLocale] ?? null) {
return $result;
}
// all options for long/short actual locale have been exhausted,
// revert to the list of fallback locales and try with each of them
foreach (static::fallbacks() as $locale) {
// first on the original input
if ($result = $key[$locale] ?? null) {
return $result;
}
// then on the fallback
if ($result = $fallback[$locale] ?? null) {
return $result;
}
}
// if a string was provided as fallback, use that one
if (is_string($fallback) === true) {
return $fallback;
}
// otherwise the first array element of the input
// or the first array element of the fallback
if (is_array($key) === true) {
return reset($key);
}
if (is_array($fallback) === true) {
return reset($fallback);
}
return null;
}
/**
@@ -210,8 +246,8 @@ class I18n
{
$locale ??= static::locale();
if (isset(static::$translations[$locale]) === true) {
return static::$translations[$locale];
if ($translation = static::$translations[$locale] ?? null) {
return $translation;
}
if (static::$load instanceof Closure) {
@@ -219,9 +255,8 @@ class I18n
}
// try to use language code, e.g. `es` when locale is `es_ES`
$lang = Str::before($locale, '_');
if (isset(static::$translations[$lang]) === true) {
return static::$translations[$lang];
if ($translation = static::$translations[Str::before($locale, '_')] ?? null) {
return $translation;
}
return static::$translations[$locale] = [];
@@ -240,8 +275,8 @@ class I18n
*/
protected static function decimalNumberFormatter(string $locale): NumberFormatter|null
{
if (isset(static::$decimalsFormatters[$locale]) === true) {
return static::$decimalsFormatters[$locale];
if ($formatter = static::$decimalsFormatters[$locale] ?? null) {
return $formatter;
}
if (
@@ -266,8 +301,12 @@ class I18n
*
* @param bool $formatNumber If set to `false`, the count is not formatted
*/
public static function translateCount(string $key, int $count, string $locale = null, bool $formatNumber = true)
{
public static function translateCount(
string $key,
int $count,
string $locale = null,
bool $formatNumber = true
) {
$locale ??= static::locale();
$translation = static::translate($key, null, $locale);
@@ -289,6 +328,6 @@ class I18n
$count = static::formatNumber($count, $locale);
}
return str_replace('{{ count }}', $count, $message);
return Str::template($message, compact('count'));
}
}

View File

@@ -57,7 +57,7 @@ class Iterator implements IteratorAggregate
/**
* Returns the current element
*/
public function current()
public function current(): mixed
{
return current($this->data);
}
@@ -66,7 +66,7 @@ class Iterator implements IteratorAggregate
* Moves the cursor to the previous element
* and returns it
*/
public function prev()
public function prev(): mixed
{
return prev($this->data);
}
@@ -75,7 +75,7 @@ class Iterator implements IteratorAggregate
* Moves the cursor to the next element
* and returns it
*/
public function next()
public function next(): mixed
{
return next($this->data);
}
@@ -110,7 +110,7 @@ class Iterator implements IteratorAggregate
* @param mixed $needle the element to search for
* @return int|false the index (int) of the element or false
*/
public function indexOf($needle): int|false
public function indexOf(mixed $needle): int|false
{
return array_search($needle, array_values($this->data));
}
@@ -121,33 +121,30 @@ class Iterator implements IteratorAggregate
* @param mixed $needle the element to search for
* @return int|string|false the name of the key or false
*/
public function keyOf($needle): int|string|false
public function keyOf(mixed $needle): int|string|false
{
return array_search($needle, $this->data);
}
/**
* Checks by key if an element is included
*
* @param mixed $key
*/
public function has($key): bool
public function has(mixed $key): bool
{
return isset($this->data[$key]) === true;
}
/**
* Checks if the current key is set
*
* @param mixed $key the key to check
*/
public function __isset($key): bool
public function __isset(mixed $key): bool
{
return $this->has($key);
}
/**
* Simplified var_dump output
* @codeCoverageIgnore
*/
public function __debugInfo(): array
{

View File

@@ -0,0 +1,48 @@
<?php
namespace Kirby\Toolkit;
use Closure;
/**
* Store a lazy value (safe from processing inside a closure)
* in this class wrapper to also protect it from being unwrapped
* by normal `Closure`/`is_callable()` checks
*
* @package Kirby Toolkit
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class LazyValue
{
public function __construct(
protected Closure $value
) {
}
/**
* Resolve the lazy value to its actual value
*/
public function resolve(mixed ...$args): mixed
{
return call_user_func_array($this->value, $args);
}
/**
* Unwrap a single value or an array of values
*/
public static function unwrap(mixed $data, mixed ...$args): mixed
{
if (is_array($data) === true) {
return A::map($data, fn ($value) => static::unwrap($value, $args));
}
if ($data instanceof static) {
return $data->resolve(...$args);
}
return $data;
}
}

View File

@@ -21,8 +21,12 @@ class Locale
* List of all locale constants supported by PHP
*/
public const LOCALE_CONSTANTS = [
'LC_COLLATE', 'LC_CTYPE', 'LC_MONETARY',
'LC_NUMERIC', 'LC_TIME', 'LC_MESSAGES'
'LC_COLLATE',
'LC_CTYPE',
'LC_MONETARY',
'LC_NUMERIC',
'LC_TIME',
'LC_MESSAGES'
];
/**

View File

@@ -34,6 +34,7 @@ class Obj extends stdClass
/**
* Improved `var_dump` output
* @codeCoverageIgnore
*/
public function __debugInfo(): array
{

View File

@@ -16,10 +16,6 @@ use Kirby\Exception\Exception;
*/
class Pagination
{
use Properties {
setProperties as protected baseSetProperties;
}
/**
* The current page
*/
@@ -47,7 +43,44 @@ class Pagination
*/
public function __construct(array $props = [])
{
$this->setProperties($props);
$this->setLimit($props['limit'] ?? 20);
$this->setPage($props['page'] ?? null);
$this->setTotal($props['total'] ?? 0);
// ensure that page is set to something, otherwise
// generate "default page" based on other params
$this->page ??= $this->firstPage();
// allow a page value of 1 even if there are no pages;
// otherwise the exception will get thrown for this pretty common case
$min = $this->firstPage();
$max = $this->pages();
if ($this->page === 1 && $max === 0) {
$this->page = 0;
}
// validate page based on all params if validation is enabled,
// otherwise limit the page number to the bounds
if ($this->page < $min || $this->page > $max) {
if (static::$validate === true) {
throw new ErrorPageException('Pagination page ' . $this->page . ' does not exist, expected ' . $min . '-' . $max);
}
$this->page = max(min($this->page, $max), $min);
}
}
/**
* Creates a new instance while
* merging initial and new properties
*/
public function clone(array $props = []): static
{
return new static(array_replace_recursive([
'page' => $this->page,
'limit' => $this->limit,
'total' => $this->total
], $props));
}
/**
@@ -260,7 +293,7 @@ class Pagination
return range($start, $end);
}
$middle = (int)floor($range/2);
$middle = (int)floor($range / 2);
$start = $page - $middle + ($range % 2 === 0);
$end = $start + $range - 1;
@@ -294,42 +327,6 @@ class Pagination
return array_pop($range);
}
/**
* Sets the properties limit, total and page
* and validates that the properties match
*
* @param array $props Array with keys limit, total and/or page
* @return $this
*/
protected function setProperties(array $props): static
{
$this->baseSetProperties($props);
// ensure that page is set to something, otherwise
// generate "default page" based on other params
$this->page ??= $this->firstPage();
// allow a page value of 1 even if there are no pages;
// otherwise the exception will get thrown for this pretty common case
$min = $this->firstPage();
$max = $this->pages();
if ($this->page === 1 && $max === 0) {
$this->page = 0;
}
// validate page based on all params if validation is enabled,
// otherwise limit the page number to the bounds
if ($this->page < $min || $this->page > $max) {
if (static::$validate === true) {
throw new ErrorPageException('Pagination page ' . $this->page . ' does not exist, expected ' . $min . '-' . $max);
}
$this->page = max(min($this->page, $max), $min);
}
return $this;
}
/**
* Sets the number of items per page
*

View File

@@ -7,6 +7,8 @@ use ReflectionMethod;
/**
* Properties
* @deprecated 4.0.0 Will be remove in Kirby 5
* @codeCoverageIgnore
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>

View File

@@ -1,251 +0,0 @@
<?php
namespace Kirby\Toolkit;
use Closure;
use Kirby\Cms\Helpers;
use Kirby\Exception\BadMethodCallException;
use Kirby\Exception\InvalidArgumentException;
/**
* The Query class can be used to
* query arrays and objects, including their
* methods with a very simple string-based syntax.
*
* @package Kirby Toolkit
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*
* @deprecated 3.8.2 Use `Kirby\Query\Query` instead
* // TODO: Remove in 3.10.0
*/
class Query
{
public const PARTS = '!\.|(\(([^()]+|(?1))*+\))(*SKIP)(*FAIL)!'; // split by dot, but not inside (nested) parens
public const PARAMETERS = '!,|' . self::SKIP . '!'; // split by comma, but not inside skip groups
public const NO_PNTH = '\([^(]+\)(*SKIP)(*FAIL)';
public const NO_SQBR = '\[[^]]+\](*SKIP)(*FAIL)';
public const NO_DLQU = '\"(?:[^"\\\\]|\\\\.)*\"(*SKIP)(*FAIL)'; // allow \" escaping inside string
public const NO_SLQU = '\'(?:[^\'\\\\]|\\\\.)*\'(*SKIP)(*FAIL)'; // allow \' escaping inside string
public const SKIP = self::NO_PNTH . '|' . self::NO_SQBR . '|' .
self::NO_DLQU . '|' . self::NO_SLQU;
/**
* The query string
*
* @var string
*/
protected $query;
/**
* Queryable data
*
* @var array
*/
protected $data;
/**
* Creates a new Query object
*
* @param string|null $query
* @param array|object $data
*/
public function __construct(string|null $query = null, $data = [])
{
$this->query = $query;
$this->data = $data;
Helpers::deprecated('The `Toolkit\Query` class has been deprecated and will be removed in a future version. Use `Query\Query` instead: Kirby\Query\Query::factory($query)->resolve($data).', 'toolkit-query-class');
}
/**
* Returns the query result if anything
* can be found, otherwise returns null
*
* @return mixed
*/
public function result()
{
if (empty($this->query) === true) {
return $this->data;
}
return $this->resolve($this->query);
}
/**
* Resolves the query if anything
* can be found, otherwise returns null
*
* @param string $query
* @return mixed
*
* @throws \Kirby\Exception\BadMethodCallException If an invalid method is accessed by the query
*/
protected function resolve(string $query)
{
// direct key access in arrays
if (is_array($this->data) === true && array_key_exists($query, $this->data) === true) {
return $this->data[$query];
}
$parts = $this->parts($query);
$data = $this->data;
$value = null;
foreach ($parts as $part) {
$info = $this->part($part);
$method = $info['method'];
$args = $info['args'];
if (is_array($data)) {
if (array_key_exists($method, $data) === true) {
$value = $data[$method];
if ($value instanceof Closure) {
$value = $value(...$args);
} elseif ($args !== []) {
throw new InvalidArgumentException('Cannot access array element ' . $method . ' with arguments');
}
} else {
static::accessError($data, $method, 'property');
}
} elseif (is_object($data)) {
if (
method_exists($data, $method) === true ||
method_exists($data, '__call') === true
) {
$value = $data->$method(...$args);
} elseif (
$args === [] && (
property_exists($data, $method) === true ||
method_exists($data, '__get') === true
)
) {
$value = $data->$method;
} else {
$label = ($args === []) ? 'method/property' : 'method';
static::accessError($data, $method, $label);
}
} else {
// further parts on a scalar/null value
static::accessError($data, $method, 'method/property');
}
// continue with the current value for the next part
$data = $value;
}
return $value;
}
/**
* Breaks the query string down into its components
*
* @param string $query
* @return array
*/
protected function parts(string $query): array
{
return preg_split(self::PARTS, trim($query), -1, PREG_SPLIT_NO_EMPTY);
}
/**
* Analyzes each part of the query string and
* extracts methods and method arguments
*
* @param string $part
* @return array
*/
protected function part(string $part): array
{
if (Str::endsWith($part, ')') === true) {
$method = Str::before($part, '(');
// the args are everything inside the *outer* parentheses
$args = Str::substr($part, Str::position($part, '(') + 1, -1);
$args = preg_split(static::PARAMETERS, $args);
$args = array_map([$this, 'parameter'], $args);
return compact('method', 'args');
}
return [
'method' => $part,
'args' => []
];
}
/**
* Converts a parameter of a query to
* its proper native PHP type
*
* @param string $arg
* @return mixed
*/
protected function parameter(string $arg)
{
$arg = trim($arg);
// string with double quotes
if (substr($arg, 0, 1) === '"' && substr($arg, -1) === '"') {
return str_replace('\"', '"', substr($arg, 1, -1));
}
// string with single quotes
if (substr($arg, 0, 1) === "'" && substr($arg, -1) === "'") {
return str_replace("\'", "'", substr($arg, 1, -1));
}
// boolean or null
switch ($arg) {
case 'null':
return null;
case 'false':
return false;
case 'true':
return true;
}
// numeric
if (is_numeric($arg) === true) {
return (float)$arg;
}
// array: split and recursive sanitizing
if (substr($arg, 0, 1) === '[' && substr($arg, -1) === ']') {
$arg = substr($arg, 1, -1);
$arg = preg_split(self::PARAMETERS, $arg);
return array_map([$this, 'parameter'], $arg);
}
// resolve parameter for objects and methods itself
return $this->resolve($arg);
}
/**
* Throws an exception for an access to an invalid method
*
* @param mixed $data Variable on which the access was tried
* @param string $name Name of the method/property that was accessed
* @param string $label Type of the name (`method`, `property` or `method/property`)
* @return void
*
* @throws \Kirby\Exception\BadMethodCallException
*/
protected static function accessError($data, string $name, string $label): void
{
$type = strtolower(gettype($data));
if ($type === 'double') {
$type = 'float';
}
$nonExisting = in_array($type, ['array', 'object']) ? 'non-existing ' : '';
$error = 'Access to ' . $nonExisting . $label . ' ' . $name . ' on ' . $type;
throw new BadMethodCallException($error);
}
}

View File

@@ -15,7 +15,7 @@ namespace Kirby\Toolkit;
*/
class Silo
{
public static $data = [];
public static array $data = [];
/**
* Setter for new data

View File

@@ -6,6 +6,7 @@ use Closure;
use DateTime;
use Exception;
use IntlDateFormatter;
use Kirby\Cms\App;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Query\Query;
use Throwable;
@@ -279,6 +280,17 @@ class Str
return lcfirst(static::studly($value));
}
/**
* Converts a camel-case string to kebab-case
* @since 4.0.0
*
* @param string $value The string to convert
*/
public static function camelToKebab(string $value = null): string
{
return static::lower(preg_replace('!([a-z0-9])([A-Z])!', '$1-$2', $value));
}
/**
* Checks if a str contains another string
*/
@@ -299,12 +311,13 @@ class Str
* Convert timestamp to date string
* according to locale settings
*
* @param string $handler date, intl or strftime
* @param 'date'|'intl'|'strftime'|null $handler Custom date handler or `null`
* for the globally configured one
*/
public static function date(
int|null $time = null,
string|IntlDateFormatter $format = null,
string $handler = 'date'
string|null $handler = null
): string|int|false {
if (is_null($format) === true) {
return $time;
@@ -315,6 +328,13 @@ class Str
return $format->format($time ?? time());
}
// automatically determine the handler from global configuration
// if an app instance is already running; otherwise fall back to
// `date` for backwards-compatibility
if ($handler === null) {
$handler = App::instance(null, true)?->option('date.handler') ?? 'date';
}
// `intl` handler
if ($handler === 'intl') {
$datetime = new DateTime();
@@ -367,7 +387,8 @@ class Str
for ($i = 0; $i < static::length($string); $i++) {
$char = static::substr($string, $i, 1);
list(, $code) = unpack('N', mb_convert_encoding($char, 'UCS-4BE', 'UTF-8'));
$char = mb_convert_encoding($char, 'UCS-4BE', 'UTF-8');
list(, $code) = unpack('N', $char);
$encoded .= rand(1, 2) === 1 ? '&#' . $code . ';' : '&#x' . dechex($code) . ';';
}
@@ -480,13 +501,21 @@ class Str
// make sure $value is not null
$value ??= '';
// turn the value into a string
$value = (string)$value;
// Convert exponential to decimal, 1e-8 as 0.00000001
if (strpos(strtolower($value), 'e') !== false) {
$value = rtrim(sprintf('%.16f', (float)$value), '0');
}
$value = str_replace(',', '.', $value);
$decimal = strlen(substr(strrchr($value, '.'), 1));
$decimal = strrchr($value, '.');
$decimal = match ($decimal) {
false => 0,
default => strlen($decimal) - 1
};
return number_format((float)$value, $decimal, '.', '');
}
@@ -538,6 +567,18 @@ class Str
return static::snake($value, '-');
}
/**
* Convert a kebab case string to camel case.
*/
public static function kebabToCamel(string $value = null): string
{
return ucfirst(preg_replace_callback(
'/-(.)/',
fn ($matches) => strtoupper($matches[1]),
$value ?? ''
));
}
/**
* A UTF-8 safe version of strlen()
*/
@@ -571,8 +612,12 @@ class Str
* @param int $offset Positional offset in the string to start the search
* @return array|null The matches or null if no match was found
*/
public static function match(string $string, string $pattern, int $flags = 0, int $offset = 0): ?array
{
public static function match(
string $string,
string $pattern,
int $flags = 0,
int $offset = 0
): array|null {
$result = preg_match($pattern, $string, $matches, $flags, $offset);
return ($result === 1) ? $matches : null;
}
@@ -586,8 +631,12 @@ class Str
* @param int $offset Positional offset in the string to start the search
* @return bool True if the string matches the pattern
*/
public static function matches(string $string, string $pattern, int $flags = 0, int $offset = 0): bool
{
public static function matches(
string $string,
string $pattern,
int $flags = 0,
int $offset = 0
): bool {
return static::match($string, $pattern, $flags, $offset) !== null;
}
@@ -600,8 +649,12 @@ class Str
* @param int $offset Positional offset in the string to start the search
* @return array|null The matches or null if no match was found
*/
public static function matchAll(string $string, string $pattern, int $flags = 0, int $offset = 0): ?array
{
public static function matchAll(
string $string,
string $pattern,
int $flags = 0,
int $offset = 0
): array|null {
$result = preg_match_all($pattern, $string, $matches, $flags, $offset);
return ($result > 0) ? $matches : null;
}
@@ -613,9 +666,9 @@ class Str
string|array $type,
bool $array = true
): string|array {
$pool = [];
if (is_array($type) === true) {
$pool = [];
foreach ($type as $t) {
$pool = array_merge($pool, static::pool($t));
}
@@ -628,7 +681,7 @@ class Str
'alphanum' => static::pool(['alpha', 'num']),
'base32' => array_merge(static::pool('alphaUpper'), range(2, 7)),
'base32hex' => array_merge(range(0, 9), range('A', 'V')),
default => $pool
default => []
};
}
@@ -685,7 +738,8 @@ class Str
return false;
}
// regex that matches all characters *not* in the pool of allowed characters
// regex that matches all characters
// *not* in the pool of allowed characters
$regex = '/[^' . $pool . ']/';
// collect characters until we have our required length
@@ -694,7 +748,8 @@ class Str
while (($currentLength = strlen($result)) < $length) {
$missing = $length - $currentLength;
$bytes = random_bytes($missing);
$result .= substr(preg_replace($regex, '', base64_encode($bytes)), 0, $missing);
$allowed = preg_replace($regex, '', base64_encode($bytes));
$result .= substr($allowed, 0, $missing);
}
return $result;
@@ -704,24 +759,21 @@ class Str
* Replaces all or some occurrences of the search string with the replacement string
* Extension of the str_replace() function in PHP with an additional $limit parameter
*
* @param string|array $string String being replaced on (haystack);
* can be an array of multiple subject strings
* @param string|array $search Value being searched for (needle)
* @param string|array $replace Value to replace matches with
* @param string|array|Collection $string String being replaced on (haystack); can be an array of multiple subject strings
* @param string|array|Collection $search Value being searched for (needle)
* @param string|array|Collection $replace Value to replace matches with
* @param int|array $limit Maximum possible replacements for each search value;
* multiple limits for each search value are supported;
* defaults to no limit
* @return string|array String with replaced values;
* if $string is an array, array of strings
* @psalm-return ($string is array ? array : string)
*
* @todo the types aren't correct, refactor to apply native type hinting
*/
public static function replace(
$string,
$search,
$replace,
$limit = -1
string|array|Collection $string,
string|array|Collection $search,
string|array|Collection $replace,
int|array $limit = -1
): string|array {
// convert Kirby collections to arrays
if ($string instanceof Collection) {
@@ -749,9 +801,11 @@ class Str
// multiple subjects are run separately through this method
if (is_array($string) === true) {
$result = [];
foreach ($string as $s) {
$result[] = static::replace($s, $search, $replace, $limit);
}
return $result;
}
@@ -775,49 +829,44 @@ class Str
* defaults to no limit
* @return array List of replacement arrays, each with a
* 'search', 'replace' and 'limit' attribute
*
* @todo the types aren't correct, refactor to apply native type hinting
*/
public static function replacements(
$search,
$replace,
$limit
string|array $search,
string|array $replace,
int|array $limit
): array {
$replacements = [];
if (is_array($search) === true) {
$replacements = [];
if (is_array($search) === true && is_array($replace) === true) {
foreach ($search as $i => $s) {
// replace with an empty string if no replacement string was defined for this index;
// behavior is identical to the official PHP str_replace()
$r = $replace[$i] ?? '';
if (is_array($limit) === true) {
// don't apply a limit if no limit was defined for this index
$l = $limit[$i] ?? -1;
} else {
$l = $limit;
if (is_array($replace) === true) {
// replace with an empty string if
// no replacement string was defined for this index;
// behavior is identical to official PHP str_replace()
$r = $replace[$i] ?? '';
}
$replacements[] = ['search' => $s, 'replace' => $r, 'limit' => $l];
}
} elseif (is_array($search) === true && is_string($replace) === true) {
foreach ($search as $i => $s) {
if (is_array($limit) === true) {
// don't apply a limit if no limit was defined for this index
// don't apply a limit if no limit
// was defined for this index
$l = $limit[$i] ?? -1;
} else {
$l = $limit;
}
$replacements[] = ['search' => $s, 'replace' => $replace, 'limit' => $l];
$replacements[] = [
'search' => $s,
'replace' => $r ?? $replace,
'limit' => $l ?? $limit
];
}
} elseif (is_string($search) === true && is_string($replace) === true && is_int($limit) === true) {
$replacements[] = compact('search', 'replace', 'limit');
} else {
throw new Exception('Invalid combination of $search, $replace and $limit params.');
return $replacements;
}
return $replacements;
if (is_string($replace) === true && is_int($limit) === true) {
return [compact('search', 'replace', 'limit')];
}
throw new InvalidArgumentException('Invalid combination of $search, $replace and $limit params.');
}
/**
@@ -846,15 +895,27 @@ class Str
$replacement['replace'],
$string
);
} elseif ($replacement['limit'] > 0) {
continue;
}
if ($replacement['limit'] > 0) {
// limit given, only replace for as many times per replacement
$position = -1;
for ($i = 0; $i < $replacement['limit']; $i++) {
$position = strpos($string, $replacement['search'], $position + 1);
$position = strpos(
$string,
$replacement['search'],
$position + 1
);
if (is_int($position) === true) {
$string = substr_replace($string, $replacement['replace'], $position, strlen($replacement['search']));
$string = substr_replace(
$string,
$replacement['replace'],
$position,
strlen($replacement['search'])
);
// adapt $pos to the now changed offset
$position = $position + strlen($replacement['replace']) - strlen($replacement['search']);
} else {
@@ -870,10 +931,6 @@ class Str
/**
* Safe rtrim alternative
*
* @param string $string
* @param string $trim
* @return string
*/
public static function rtrim(string $string, string $trim = ' '): string
{
@@ -1073,11 +1130,19 @@ class Str
$string = static::ascii($string);
// replace spaces with simple dashes
$string = preg_replace('![^' . $allowed . ']!i', $separator, $string);
$string = preg_replace(
'![^' . $allowed . ']!i',
$separator,
$string
);
if (strlen($separator) > 0) {
// remove double separators
$string = preg_replace('![' . preg_quote($separator) . ']{2,}!', $separator, $string);
$string = preg_replace(
'![' . preg_quote($separator) . ']{2,}!',
$separator,
$string
);
}
// replace slashes with dashes
@@ -1088,7 +1153,7 @@ class Str
$string = preg_replace('![^a-z0-9]+$!', '', $string);
// cut the string after the given maxlength
return static::short($string, $maxlength, false);
return static::short($string, $maxlength, '');
}
/**
@@ -1134,7 +1199,10 @@ class Str
foreach ($parts as $p) {
$p = trim($p);
if (static::length($p) > 0 && static::length($p) >= $length) {
if (
static::length($p) > 0 &&
static::length($p) >= $length
) {
$out[] = $p;
}
}
@@ -1165,7 +1233,9 @@ class Str
*/
public static function studly(string $value = null): string
{
return str_replace(' ', '', ucwords(str_replace(['-', '_'], ' ', $value)));
$value = str_replace(['-', '_'], ' ', $value);
$value = ucwords($value);
return str_replace(' ', '', $value);
}
/**
@@ -1205,8 +1275,8 @@ class Str
array $data = [],
array $options = []
): string {
$start = (string)($options['start'] ?? '{{');
$end = (string)($options['end'] ?? '}}');
$start = $options['start'] ?? '{{1,2}';
$end = $options['end'] ?? '}{1,2}';
$fallback = $options['fallback'] ?? null;
$callback = $options['callback'] ?? null;
@@ -1219,7 +1289,7 @@ class Str
return preg_replace_callback(
'!' . $start . '(.*?)' . $end . '!',
function ($match) use ($data, $fallback, $callback) {
function (array $match) use ($data, $fallback, $callback) {
$query = trim($match[1]);
try {
@@ -1233,12 +1303,12 @@ class Str
// callback on result if given
if ($callback !== null) {
$callbackResult = $callback((string)$result, $query, $data);
$callback = $callback((string)$result, $query, $data);
if ($result !== null || $callbackResult !== '') {
if ($result !== null || $callback !== '') {
// the empty string came just from string casting,
// keep the null value and ignore the callback result
$result = $callbackResult;
$result = $callback;
}
}
@@ -1256,7 +1326,7 @@ class Str
public static function toBytes(string $size): int
{
$size = trim($size);
$last = strtolower($size[strlen($size)-1] ?? '');
$last = strtolower($size[strlen($size) - 1] ?? '');
$size = (int)$size;
$size *= match ($last) {

144
kirby/src/Toolkit/Totp.php Normal file
View File

@@ -0,0 +1,144 @@
<?php
namespace Kirby\Toolkit;
use Base32\Base32;
use Kirby\Exception\InvalidArgumentException;
use SensitiveParameter;
/**
* The TOTP class handles the generation and verification
* of time-based one-time passwords according to RFC6238
* with the SHA1 algorithm, 30 second intervals and 6 digits
* @since 4.0.0
*
* @package Kirby Toolkit
* @author Lukas Bestle <lukas@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://opensource.org/licenses/MIT
*/
class Totp
{
/**
* Binary secret
*/
protected string $secret;
/**
* Class constructor
*
* @param string|null $secret Existing secret in Base32 format
* or `null` to generate a new one
* @param bool $force Whether to skip the secret length validation;
* WARNING: Only ever set this to `true` when
* generating codes for third-party services
*/
public function __construct(
#[SensitiveParameter]
string|null $secret = null,
bool $force = false
) {
// if provided, decode the existing secret into binary
if ($secret !== null) {
$this->secret = Base32::decode($secret);
}
// otherwise generate a new one;
// 20 bytes are the length of the SHA1 HMAC
$this->secret ??= random_bytes(20);
// safety check to avoid accidental insecure secrets
if ($force === false && strlen($this->secret) !== 20) {
throw new InvalidArgumentException('TOTP secrets should be 32 Base32 digits (= 20 bytes)');
}
}
/**
* Generates the current TOTP code
*
* @param int $offset Optional counter offset to generate
* previous or upcoming codes
*/
public function generate(int $offset = 0): string
{
// generate a new code every 30 seconds
$counter = floor(time() / 30) + $offset;
// pack the number into a binary 64-bit unsigned int
$binaryCounter = pack('J', $counter);
// on 32-bit systems, we need to pack into a binary 32-bit
// unsigned int and prepend 4 null bytes to get a 64-bit value
// @codeCoverageIgnoreStart
if (PHP_INT_SIZE < 8) {
$binaryCounter = "\0\0\0\0" . pack('N', $counter);
}
// @codeCoverageIgnoreEnd
// create a binary HMAC from the binary counter and the binary secret
$binaryHmac = hash_hmac('sha1', $binaryCounter, $this->secret, true);
// convert the HMAC into an array of byte values (from 0-255)
$bytes = unpack('C*', $binaryHmac);
// perform dynamic truncation to four bytes according to RFC6238 & RFC4226
$byteOffset = (end($bytes) & 0xF);
$code = (($bytes[$byteOffset + 1] & 0x7F) << 24) |
($bytes[$byteOffset + 2] << 16) |
($bytes[$byteOffset + 3] << 8) |
$bytes[$byteOffset + 4];
// truncate the resulting number to at max six digits
$code %= 1000000;
// format as a six-digit string, left-padded with zeros
return sprintf('%06d', $code);
}
/**
* Returns the secret in human-readable Base32 format
*/
public function secret(): string
{
return Base32::encode($this->secret);
}
/**
* Returns a `otpauth://` URI for use in a setup QR code or link
*
* @param string $issuer Name of the site the code is valid for
* @param string $label Account name the code is valid for
*/
public function uri(string $issuer, string $label): string
{
$query = http_build_query([
'secret' => $this->secret(),
'issuer' => $issuer
], '', '&', PHP_QUERY_RFC3986);
return 'otpauth://totp/' . rawurlencode($issuer) .
':' . rawurlencode($label) . '?' . $query;
}
/**
* Securely checks the provided TOTP code against the
* current, the direct previous and following codes
*/
public function verify(string $totp): bool
{
// strip out any non-numeric character (e.g. spaces)
// from user input to increase UX
$totp = preg_replace('/[^0-9]/', '', $totp);
// also allow the previous and upcoming codes
// to account for time sync issues
foreach ([0, -1, 1] as $offset) {
if (hash_equals($this->generate($offset), $totp) === true) {
return true;
}
}
return false;
}
}

View File

@@ -4,7 +4,7 @@ namespace Kirby\Toolkit;
use Countable;
use Exception;
use Kirby\Cms\Field;
use Kirby\Content\Field;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Http\Idn;
use Kirby\Uuid\Uuid;
@@ -449,7 +449,7 @@ V::$validators = [
* Checks if the value matches the given regular expression
*/
'match' => function ($value, string $pattern): bool {
return preg_match($pattern, $value) !== 0;
return preg_match($pattern, (string)$value) === 1;
},
/**
@@ -596,6 +596,13 @@ V::$validators = [
return Str::startsWith($value, $start);
},
/**
* Checks for a valid unformatted telephone number
*/
'tel' => function ($value): bool {
return V::match($value, '!^[+]{0,1}[0-9]+$!');
},
/**
* Checks for valid time
*/

View File

@@ -28,8 +28,7 @@ class View
}
/**
* Returns the view's data array
* without globals.
* Returns the view's data array without globals
*/
public function data(): array
{
@@ -71,7 +70,6 @@ class View
ob_start();
$exception = null;
try {
F::load($this->file(), null, $this->data());
} catch (Throwable $e) {
@@ -81,11 +79,11 @@ class View
$content = ob_get_contents();
ob_end_clean();
if ($exception === null) {
return $content;
if (($exception ?? null) !== null) {
throw $exception;
}
throw $exception;
return $content;
}
/**
@@ -99,6 +97,8 @@ class View
/**
* Magic string converter to enable
* converting view objects to string
*
* @see ::render()
*/
public function __toString(): string
{

View File

@@ -94,32 +94,23 @@ class Xml
return implode(' ', $attributes);
}
// TODO: In 3.10, treat $value === '' to render as name=""
if ($value === null || $value === '' || $value === []) {
// TODO: Remove in 3.10
// @codeCoverageIgnoreStart
if ($value === '') {
Helpers::deprecated('Passing an empty string as value to `Xml::attr()` has been deprecated. In a future version, passing an empty string won\'t omit the attribute anymore but render it with an empty value. To omit the attribute, please pass `null`.', 'xml-attr-empty-string');
}
// @codeCoverageIgnoreEnd
if ($value === null || $value === false || $value === []) {
return null;
}
// TODO: In 3.10, add deprecation message for space = empty attribute
// TODO: In 3.11, render space as space
// TODO: In 5.0, remove this block to render space as space
// @codeCoverageIgnoreStart
if ($value === ' ') {
Helpers::deprecated('Passing a single space as value to `Xml::attr()` has been deprecated. In a future version, passing a single space won\'t render an empty value anymore but a single space. To render an empty value, please pass an empty string.', 'xml-attr-single-space');
return $name . '=""';
}
// @codeCoverageIgnoreEnd
if ($value === true) {
return $name . '="' . $name . '"';
}
if ($value === false) {
return null;
}
if (is_array($value) === true) {
if (isset($value['value'], $value['escape'])) {
$value = $value['escape'] === true ? static::encode($value['value']) : $value['value'];