init
This commit is contained in:
991
kirby/src/Toolkit/A.php
Normal file
991
kirby/src/Toolkit/A.php
Normal file
@@ -0,0 +1,991 @@
|
||||
<?php
|
||||
|
||||
namespace Kirby\Toolkit;
|
||||
|
||||
use Closure;
|
||||
use Exception;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* The `A` class provides a set of handy methods
|
||||
* to simplify array handling and make it more
|
||||
* consistent. The class contains methods for
|
||||
* fetching elements from arrays, merging and
|
||||
* sorting or shuffling arrays.
|
||||
*
|
||||
* @package Kirby Toolkit
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class A
|
||||
{
|
||||
/**
|
||||
* Appends the given array
|
||||
*/
|
||||
public static function append(array $array, array $append): array
|
||||
{
|
||||
return static::merge($array, $append, A::MERGE_APPEND);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively loops through the array and
|
||||
* resolves any item defined as `Closure`,
|
||||
* applying the passed parameters
|
||||
* @since 3.5.6
|
||||
*
|
||||
* @param mixed ...$args Parameters to pass to the closures
|
||||
*/
|
||||
public static function apply(array $array, mixed ...$args): array
|
||||
{
|
||||
array_walk_recursive($array, function (&$item) use ($args) {
|
||||
if ($item instanceof Closure) {
|
||||
$item = $item(...$args);
|
||||
}
|
||||
});
|
||||
|
||||
return $array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the average value of an array
|
||||
*
|
||||
* @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
|
||||
*
|
||||
* <code>
|
||||
* $array = [1, 30, 39, 29, 10, 13];
|
||||
*
|
||||
* $isBelowThreshold = fn($value) => $value < 40;
|
||||
* echo A::every($array, $isBelowThreshold) ? 'true' : 'false';
|
||||
* // output: 'true'
|
||||
*
|
||||
* $isIntegerKey = fn($value, $key) => is_int($key);
|
||||
* echo A::every($array, $isIntegerKey) ? 'true' : 'false';
|
||||
* // output: 'true'
|
||||
* </code>
|
||||
*
|
||||
* @since 3.9.8
|
||||
* @param callable(mixed $value, int|string $key, array $array):bool $test
|
||||
*/
|
||||
public static function every(array $array, callable $test): bool
|
||||
{
|
||||
foreach ($array as $key => $value) {
|
||||
if (!$test($value, $key, $array)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
*
|
||||
* <code>
|
||||
* $array = [1, 30, 39, 29, 10, 13];
|
||||
*
|
||||
* $isAboveThreshold = fn($value) => $value > 30;
|
||||
* echo A::find($array, $isAboveThreshold);
|
||||
* // output: '39'
|
||||
*
|
||||
* $array = [
|
||||
* 'cat' => 'miao',
|
||||
* 'cow' => 'moo',
|
||||
* 'colibri' => 'humm',
|
||||
* 'dog' => 'wuff',
|
||||
* 'chicken' => 'cluck',
|
||||
* 'bird' => 'tweet'
|
||||
* ];
|
||||
*
|
||||
* $keyNotStartingWithC = fn($value, $key) => $key[0] !== 'c';
|
||||
* echo A::find($array, $keyNotStartingWithC);
|
||||
* // output: 'wuff'
|
||||
* </code>
|
||||
*
|
||||
* @since 3.9.8
|
||||
* @param callable(mixed $value, int|string $key, array $array):bool $callback
|
||||
*/
|
||||
public static function find(array $array, callable $callback): mixed
|
||||
{
|
||||
foreach ($array as $key => $value) {
|
||||
if ($callback($value, $key, $array)) {
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
*
|
||||
* <code>
|
||||
* $array = [
|
||||
* 'cat' => 'miao',
|
||||
* 'dog' => 'wuff',
|
||||
* 'bird' => 'tweet'
|
||||
* ];
|
||||
*
|
||||
* echo A::get($array, 'cat');
|
||||
* // output: 'miao'
|
||||
*
|
||||
* echo A::get($array, 'elephant', 'shut up');
|
||||
* // output: 'shut up'
|
||||
*
|
||||
* $catAndDog = A::get($array, ['cat', 'dog']);
|
||||
* // result: ['cat' => 'miao', 'dog' => 'wuff'];
|
||||
* </code>
|
||||
*
|
||||
* @param mixed $array The source array
|
||||
* @param string|int|array|null $key The key to look for
|
||||
* @param mixed $default Optional default value, which
|
||||
* should be returned if no element
|
||||
* has been found
|
||||
*/
|
||||
public static function get(
|
||||
$array,
|
||||
string|int|array|null $key,
|
||||
mixed $default = null
|
||||
) {
|
||||
if (is_array($array) === false) {
|
||||
return $array;
|
||||
}
|
||||
|
||||
// return the entire array if the key is null
|
||||
if ($key === null) {
|
||||
return $array;
|
||||
}
|
||||
|
||||
// get an array of keys
|
||||
if (is_array($key) === true) {
|
||||
$result = [];
|
||||
foreach ($key as $k) {
|
||||
$result[$k] = static::get($array, $k, $default);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
if (isset($array[$key]) === true) {
|
||||
return $array[$key];
|
||||
}
|
||||
|
||||
// extract data from nested array structures using the dot notation
|
||||
if (strpos($key, '.') !== false) {
|
||||
$keys = explode('.', $key);
|
||||
$firstKey = array_shift($keys);
|
||||
|
||||
// if the input array also uses dot notation,
|
||||
// try to find a subset of the $keys
|
||||
if (isset($array[$firstKey]) === false) {
|
||||
$currentKey = $firstKey;
|
||||
|
||||
while ($innerKey = array_shift($keys)) {
|
||||
$currentKey .= '.' . $innerKey;
|
||||
|
||||
// the element needs to exist and also needs
|
||||
// to be an array; otherwise we cannot find the
|
||||
// remaining keys within it (invalid array structure)
|
||||
if (
|
||||
isset($array[$currentKey]) === true &&
|
||||
is_array($array[$currentKey]) === true
|
||||
) {
|
||||
// $keys only holds the remaining keys
|
||||
// that have not been shifted off yet
|
||||
return static::get(
|
||||
$array[$currentKey],
|
||||
implode('.', $keys),
|
||||
$default
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// searching through the full chain of keys wasn't successful
|
||||
return $default;
|
||||
}
|
||||
|
||||
// if the input array uses a completely nested structure,
|
||||
// recursively progress layer by layer
|
||||
if (is_array($array[$firstKey]) === true) {
|
||||
return static::get(
|
||||
$array[$firstKey],
|
||||
implode('.', $keys),
|
||||
$default
|
||||
);
|
||||
}
|
||||
|
||||
// the $firstKey element was found, but isn't an array, so we cannot
|
||||
// find the remaining keys within it (invalid array structure)
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if array has a value
|
||||
*/
|
||||
public static function has(
|
||||
array $array,
|
||||
mixed $value,
|
||||
bool $strict = false
|
||||
): bool {
|
||||
return in_array($value, $array, $strict);
|
||||
}
|
||||
|
||||
/**
|
||||
* Join array elements as a string,
|
||||
* also supporting nested arrays
|
||||
*/
|
||||
public static function implode(
|
||||
array $array,
|
||||
string $separator = ''
|
||||
): string {
|
||||
$result = '';
|
||||
|
||||
foreach ($array as $value) {
|
||||
if (empty($result) === false) {
|
||||
$result .= $separator;
|
||||
}
|
||||
|
||||
if (is_array($value) === true) {
|
||||
$value = static::implode($value, $separator);
|
||||
}
|
||||
|
||||
$result .= $value;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (is_string($value) === true) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return implode($separator, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes an array and makes it associative by an argument.
|
||||
* If the argument is a callable, it will be used to map the array.
|
||||
* If it is a string, it will be used as a key to pluck from the array.
|
||||
*
|
||||
* <code>
|
||||
* $array = [['id'=>1], ['id'=>2], ['id'=>3]];
|
||||
* $keyed = A::keyBy($array, 'id');
|
||||
*
|
||||
* // Now you can access the array by the id
|
||||
* </code>
|
||||
*/
|
||||
public static function keyBy(array $array, string|callable $keyBy): array
|
||||
{
|
||||
$keys =
|
||||
is_callable($keyBy) ?
|
||||
static::map($array, $keyBy) :
|
||||
static::pluck($array, $keyBy);
|
||||
|
||||
if (count($keys) !== count($array)) {
|
||||
throw new InvalidArgumentException('The "key by" argument must be a valid key or a callable');
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Merges arrays recursively
|
||||
*
|
||||
* If last argument is an integer, it defines the
|
||||
* behavior for elements with numeric keys;
|
||||
* - A::MERGE_OVERWRITE: elements are overwritten, keys are preserved
|
||||
* - A::MERGE_APPEND: elements are appended, keys are reset;
|
||||
* - A::MERGE_REPLACE: non-associative arrays are completely replaced
|
||||
*/
|
||||
public static function merge(array|int ...$arrays): array
|
||||
{
|
||||
// get mode from parameters
|
||||
$last = A::last($arrays);
|
||||
$mode = is_int($last) ? array_pop($arrays) : A::MERGE_APPEND;
|
||||
|
||||
// get the first two arrays that should be merged
|
||||
$merged = array_shift($arrays);
|
||||
$join = array_shift($arrays);
|
||||
|
||||
if (
|
||||
static::isAssociative($merged) === false &&
|
||||
$mode === static::MERGE_REPLACE
|
||||
) {
|
||||
$merged = $join;
|
||||
} else {
|
||||
foreach ($join as $key => $value) {
|
||||
// append to the merged array, don't overwrite numeric keys
|
||||
if (
|
||||
is_int($key) === true &&
|
||||
$mode === static::MERGE_APPEND
|
||||
) {
|
||||
$merged[] = $value;
|
||||
|
||||
// recursively merge the two array values
|
||||
} elseif (
|
||||
is_array($value) === true &&
|
||||
isset($merged[$key]) === true &&
|
||||
is_array($merged[$key]) === true
|
||||
) {
|
||||
$merged[$key] = static::merge($merged[$key], $value, $mode);
|
||||
|
||||
// simply overwrite with the value from the second array
|
||||
} else {
|
||||
$merged[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if ($mode === static::MERGE_APPEND) {
|
||||
// the keys don't make sense anymore, reset them
|
||||
// array_merge() is the simplest way to renumber
|
||||
// arrays that have both numeric and string keys;
|
||||
// besides the keys, nothing changes here
|
||||
$merged = array_merge($merged, []);
|
||||
}
|
||||
}
|
||||
|
||||
// if more than two arrays need to be merged, add the result
|
||||
// as first array and the mode to the end and call the method again
|
||||
if (count($arrays) > 0) {
|
||||
array_unshift($arrays, $merged);
|
||||
array_push($arrays, $mode);
|
||||
return static::merge(...$arrays);
|
||||
}
|
||||
|
||||
return $merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plucks a single column from an array
|
||||
*
|
||||
* <code>
|
||||
* $array[] = [
|
||||
* 'id' => 1,
|
||||
* 'username' => 'homer',
|
||||
* ];
|
||||
*
|
||||
* $array[] = [
|
||||
* 'id' => 2,
|
||||
* 'username' => 'marge',
|
||||
* ];
|
||||
*
|
||||
* $array[] = [
|
||||
* 'id' => 3,
|
||||
* 'username' => 'lisa',
|
||||
* ];
|
||||
*
|
||||
* var_dump(A::pluck($array, 'username'));
|
||||
* // result: ['homer', 'marge', 'lisa'];
|
||||
* </code>
|
||||
*
|
||||
* @param array $array The source array
|
||||
* @param string $key The key name of the column to extract
|
||||
* @return array The result array with all values
|
||||
* from that column.
|
||||
*/
|
||||
public static function pluck(array $array, string $key): array
|
||||
{
|
||||
$output = [];
|
||||
|
||||
foreach ($array as $a) {
|
||||
if (isset($a[$key]) === true) {
|
||||
$output[] = $a[$key];
|
||||
}
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepends the given array
|
||||
*/
|
||||
public static function prepend(array $array, array $prepend): array
|
||||
{
|
||||
return static::merge($prepend, $array, A::MERGE_APPEND);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce an array to a single value
|
||||
*/
|
||||
public static function reduce(
|
||||
array $array,
|
||||
callable $callback,
|
||||
$initial = null
|
||||
): mixed {
|
||||
return array_reduce($array, $callback, $initial);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for missing elements in an array
|
||||
*
|
||||
* This is very handy to check for missing
|
||||
* user values in a request for example.
|
||||
*
|
||||
* <code>
|
||||
* $array = [
|
||||
* 'cat' => 'miao',
|
||||
* 'dog' => 'wuff',
|
||||
* 'bird' => 'tweet'
|
||||
* ];
|
||||
*
|
||||
* $required = ['cat', 'elephant'];
|
||||
*
|
||||
* $missing = A::missing($array, $required);
|
||||
* // missing: [
|
||||
* // 'elephant'
|
||||
* // ];
|
||||
* </code>
|
||||
*
|
||||
* @param array $array The source array
|
||||
* @param array $required An array of required keys
|
||||
* @return array An array of missing fields. If this
|
||||
* is empty, nothing is missing.
|
||||
*/
|
||||
public static function missing(array $array, array $required = []): array
|
||||
{
|
||||
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
|
||||
*
|
||||
* @param array $ignore List of keys in dot notation that should
|
||||
* not be converted to a nested structure
|
||||
*/
|
||||
public static function nest(array $array, array $ignore = []): array
|
||||
{
|
||||
// convert a simple ignore list to a nested $key => true array
|
||||
if (isset($ignore[0]) === true) {
|
||||
$ignore = array_map(fn () => true, array_flip($ignore));
|
||||
$ignore = A::nest($ignore);
|
||||
}
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($array as $fullKey => $value) {
|
||||
// extract the first part of a multi-level key, keep the others
|
||||
$subKeys = is_int($fullKey) ? [$fullKey] : explode('.', $fullKey);
|
||||
$key = array_shift($subKeys);
|
||||
|
||||
// skip the magic for ignored keys
|
||||
if (($ignore[$key] ?? null) === true) {
|
||||
$result[$fullKey] = $value;
|
||||
continue;
|
||||
}
|
||||
|
||||
// untangle elements where the key uses dot notation
|
||||
if (count($subKeys) > 0) {
|
||||
$value = static::nestByKeys($value, $subKeys);
|
||||
}
|
||||
|
||||
// now recursively do the same for each array level if needed
|
||||
if (is_array($value) === true) {
|
||||
$value = static::nest($value, $ignore[$key] ?? []);
|
||||
}
|
||||
|
||||
// merge arrays with previous results if necessary
|
||||
// (needed when the same keys are used both with and without dot notation)
|
||||
if (
|
||||
is_array($result[$key] ?? null) === true &&
|
||||
is_array($value) === true
|
||||
) {
|
||||
$value = array_replace_recursive($result[$key], $value);
|
||||
}
|
||||
|
||||
$result[$key] = $value;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively creates a nested array from a set of keys
|
||||
* with a key on each level
|
||||
*
|
||||
* @param mixed $value Arbitrary value that will end up at the bottom of the tree
|
||||
* @param array $keys List of keys to use sorted from the topmost level
|
||||
* @return array|mixed Nested array or (if `$keys` is empty) the input `$value`
|
||||
*/
|
||||
public static function nestByKeys($value, array $keys)
|
||||
{
|
||||
// shift off the first key from the list
|
||||
$firstKey = array_shift($keys);
|
||||
|
||||
// stop further recursion if there are no more keys
|
||||
if ($firstKey === null) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// return one level of the output tree, recurse further
|
||||
return [
|
||||
$firstKey => static::nestByKeys($value, $keys)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*
|
||||
* <code>
|
||||
* $array[0] = [
|
||||
* 'id' => 1,
|
||||
* 'username' => 'mike',
|
||||
* ];
|
||||
*
|
||||
* $array[1] = [
|
||||
* 'id' => 2,
|
||||
* 'username' => 'peter',
|
||||
* ];
|
||||
*
|
||||
* $array[3] = [
|
||||
* 'id' => 3,
|
||||
* 'username' => 'john',
|
||||
* ];
|
||||
*
|
||||
* $sorted = A::sort($array, 'username ASC');
|
||||
* // Array
|
||||
* // (
|
||||
* // [0] => Array
|
||||
* // (
|
||||
* // [id] => 3
|
||||
* // [username] => john
|
||||
* // )
|
||||
* // [1] => Array
|
||||
* // (
|
||||
* // [id] => 1
|
||||
* // [username] => mike
|
||||
* // )
|
||||
* // [2] => Array
|
||||
* // (
|
||||
* // [id] => 2
|
||||
* // [username] => peter
|
||||
* // )
|
||||
* // )
|
||||
*
|
||||
* </code>
|
||||
*
|
||||
* @param array $array The source array
|
||||
* @param string $field The name of the column
|
||||
* @param string $direction desc (descending) or asc (ascending)
|
||||
* @param int $method A PHP sort method flag or 'natural' for
|
||||
* natural sorting, which is not supported in
|
||||
* PHP by sort flags
|
||||
* @return array The sorted array
|
||||
*/
|
||||
public static function sort(
|
||||
array $array,
|
||||
string $field,
|
||||
string $direction = 'desc',
|
||||
int $method = SORT_REGULAR
|
||||
): array {
|
||||
$direction = strtolower($direction) === 'desc' ? SORT_DESC : SORT_ASC;
|
||||
$helper = [];
|
||||
$result = [];
|
||||
|
||||
// build the helper array
|
||||
foreach ($array as $key => $row) {
|
||||
$helper[$key] = $row[$field];
|
||||
}
|
||||
|
||||
// natural sorting
|
||||
if ($direction === SORT_DESC) {
|
||||
arsort($helper, $method);
|
||||
} else {
|
||||
asort($helper, $method);
|
||||
}
|
||||
|
||||
// rebuild the original array
|
||||
foreach ($helper as $key => $val) {
|
||||
$result[$key] = $array[$key];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sums an array
|
||||
*/
|
||||
public static function sum(array $array): int|float
|
||||
{
|
||||
return array_sum($array);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an array with a second array
|
||||
* The second array can contain callbacks as values,
|
||||
* which will get the original values as argument
|
||||
*
|
||||
* <code>
|
||||
* $user = [
|
||||
* 'username' => 'homer',
|
||||
* 'email' => 'homer@simpsons.com'
|
||||
* ];
|
||||
*
|
||||
* // simple updates
|
||||
* A::update($user, [
|
||||
* 'username' => 'homer j. simpson'
|
||||
* ]);
|
||||
*
|
||||
* // with callback
|
||||
* A::update($user, [
|
||||
* 'username' => fn ($username) => $username . ' j. simpson'
|
||||
* ]);
|
||||
* </code>
|
||||
*/
|
||||
public static function update(array $array, array $update): array
|
||||
{
|
||||
foreach ($update as $key => $value) {
|
||||
if ($value instanceof Closure) {
|
||||
$value = $value(static::get($array, $key));
|
||||
}
|
||||
|
||||
$array[$key] = $value;
|
||||
}
|
||||
|
||||
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.
|
||||
*/
|
||||
public static function wrap($array = null): array
|
||||
{
|
||||
if ($array === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (is_array($array) === false) {
|
||||
return [$array];
|
||||
}
|
||||
|
||||
return $array;
|
||||
}
|
||||
}
|
||||
1445
kirby/src/Toolkit/Collection.php
Normal file
1445
kirby/src/Toolkit/Collection.php
Normal file
File diff suppressed because it is too large
Load Diff
278
kirby/src/Toolkit/Component.php
Normal file
278
kirby/src/Toolkit/Component.php
Normal file
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
namespace Kirby\Toolkit;
|
||||
|
||||
use AllowDynamicProperties;
|
||||
use ArgumentCountError;
|
||||
use Closure;
|
||||
use Kirby\Exception\Exception;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Filesystem\F;
|
||||
use TypeError;
|
||||
|
||||
/**
|
||||
* Vue-like components
|
||||
*
|
||||
* @package Kirby Toolkit
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*
|
||||
* @todo remove the following psalm suppress when PHP >= 8.2 required
|
||||
* @psalm-suppress UndefinedAttributeClass
|
||||
*/
|
||||
#[AllowDynamicProperties]
|
||||
class Component
|
||||
{
|
||||
/**
|
||||
* Registry for all component mixins
|
||||
*/
|
||||
public static array $mixins = [];
|
||||
|
||||
/**
|
||||
* Registry for all component types
|
||||
*/
|
||||
public static array $types = [];
|
||||
|
||||
/**
|
||||
* An array of all passed attributes
|
||||
*/
|
||||
protected array $attrs = [];
|
||||
|
||||
/**
|
||||
* An array of all computed properties
|
||||
*/
|
||||
protected array $computed = [];
|
||||
|
||||
/**
|
||||
* An array of all registered methods
|
||||
*/
|
||||
protected array $methods = [];
|
||||
|
||||
/**
|
||||
* An array of all component options
|
||||
* from the component definition
|
||||
*/
|
||||
protected array|string $options = [];
|
||||
|
||||
/**
|
||||
* An array of all resolved props
|
||||
*/
|
||||
protected array $props = [];
|
||||
|
||||
/**
|
||||
* The component type
|
||||
*/
|
||||
protected string $type;
|
||||
|
||||
/**
|
||||
* Creates a new component for the given type
|
||||
*/
|
||||
public function __construct(string $type, array $attrs = [])
|
||||
{
|
||||
if (isset(static::$types[$type]) === false) {
|
||||
throw new InvalidArgumentException('Undefined component type: ' . $type);
|
||||
}
|
||||
|
||||
$this->attrs = $attrs;
|
||||
$this->options = $options = static::setup($type);
|
||||
$this->methods = $methods = $options['methods'] ?? [];
|
||||
|
||||
foreach ($attrs as $attrName => $attrValue) {
|
||||
$this->$attrName = $attrValue;
|
||||
}
|
||||
|
||||
if (isset($options['props']) === true) {
|
||||
$this->applyProps($options['props']);
|
||||
}
|
||||
|
||||
if (isset($options['computed']) === true) {
|
||||
$this->applyComputed($options['computed']);
|
||||
}
|
||||
|
||||
$this->attrs = $attrs;
|
||||
$this->methods = $methods;
|
||||
$this->options = $options;
|
||||
$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
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback for missing properties to return
|
||||
* null instead of an error
|
||||
*/
|
||||
public function __get(string $attr)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A set of default options for each component.
|
||||
* This can be overwritten by extended classes
|
||||
* to define basic options that should always
|
||||
* be applied.
|
||||
*/
|
||||
public static function defaults(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all defined props and apply the
|
||||
* passed values.
|
||||
*/
|
||||
protected function applyProps(array $props): void
|
||||
{
|
||||
foreach ($props as $name => $function) {
|
||||
if ($function instanceof Closure) {
|
||||
if (isset($this->attrs[$name]) === true) {
|
||||
try {
|
||||
$this->$name = $this->props[$name] = $function->call(
|
||||
$this,
|
||||
$this->attrs[$name]
|
||||
);
|
||||
continue;
|
||||
} catch (TypeError) {
|
||||
throw new TypeError('Invalid value for "' . $name . '"');
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all computed properties and calculate their values.
|
||||
* This must happen after all props are registered.
|
||||
*/
|
||||
protected function applyComputed(array $computed): void
|
||||
{
|
||||
foreach ($computed as $name => $function) {
|
||||
if ($function instanceof Closure) {
|
||||
$this->$name = $this->computed[$name] = $function->call($this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a component definition by type
|
||||
*/
|
||||
public static function load(string $type): array
|
||||
{
|
||||
$definition = static::$types[$type];
|
||||
|
||||
// load definitions from string
|
||||
if (is_string($definition) === true) {
|
||||
if (is_file($definition) !== true) {
|
||||
throw new Exception('Component definition ' . $definition . ' does not exist');
|
||||
}
|
||||
|
||||
static::$types[$type] = $definition = F::load(
|
||||
$definition,
|
||||
allowOutput: false
|
||||
);
|
||||
}
|
||||
|
||||
return $definition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all options from the component definition
|
||||
* mixes in the defaults from the defaults method and
|
||||
* then injects all additional mixins, defined in the
|
||||
* component options.
|
||||
*/
|
||||
public static function setup(string $type): array
|
||||
{
|
||||
// load component definition
|
||||
$definition = static::load($type);
|
||||
|
||||
if (isset($definition['extends']) === true) {
|
||||
// extend other definitions
|
||||
$options = array_replace_recursive(
|
||||
static::defaults(),
|
||||
static::load($definition['extends']),
|
||||
$definition
|
||||
);
|
||||
} else {
|
||||
// inject defaults
|
||||
$options = array_replace_recursive(static::defaults(), $definition);
|
||||
}
|
||||
|
||||
// inject mixins
|
||||
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],
|
||||
$options
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts all props and computed props to an array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$closure = $this->options['toArray'] ?? null;
|
||||
|
||||
if ($closure instanceof Closure) {
|
||||
return $closure->call($this);
|
||||
}
|
||||
|
||||
$array = array_merge($this->attrs, $this->props, $this->computed);
|
||||
|
||||
ksort($array);
|
||||
|
||||
return $array;
|
||||
}
|
||||
}
|
||||
18
kirby/src/Toolkit/Config.php
Normal file
18
kirby/src/Toolkit/Config.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Kirby\Toolkit;
|
||||
|
||||
/**
|
||||
* This is the core class to handle
|
||||
* configuration values/constants.
|
||||
*
|
||||
* @package Kirby Toolkit
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class Config extends Silo
|
||||
{
|
||||
public static array $data = [];
|
||||
}
|
||||
77
kirby/src/Toolkit/Controller.php
Normal file
77
kirby/src/Toolkit/Controller.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace Kirby\Toolkit;
|
||||
|
||||
use Closure;
|
||||
use Kirby\Filesystem\F;
|
||||
use ReflectionFunction;
|
||||
|
||||
/**
|
||||
* A smart extension of Closures with
|
||||
* magic dependency injection based on the
|
||||
* defined variable names.
|
||||
*
|
||||
* @package Kirby Toolkit
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class Controller
|
||||
{
|
||||
public function __construct(protected Closure $function)
|
||||
{
|
||||
}
|
||||
|
||||
public function arguments(array $data = []): array
|
||||
{
|
||||
$info = new ReflectionFunction($this->function);
|
||||
$args = [];
|
||||
|
||||
foreach ($info->getParameters() as $param) {
|
||||
$name = $param->getName();
|
||||
|
||||
if ($param->isVariadic() === true) {
|
||||
// variadic ... argument collects all remaining values
|
||||
$args += $data;
|
||||
} elseif (isset($data[$name]) === true) {
|
||||
// use provided argument value if available
|
||||
$args[$name] = $data[$name];
|
||||
} elseif ($param->isDefaultValueAvailable() === false) {
|
||||
// use null for any other arguments that don't define
|
||||
// a default value for themselves
|
||||
$args[$name] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return $args;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return $this->function->call($bind, ...$args);
|
||||
}
|
||||
|
||||
public static function load(string $file): static|null
|
||||
{
|
||||
if (is_file($file) === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$function = F::load($file);
|
||||
|
||||
if ($function instanceof Closure === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new static($function);
|
||||
}
|
||||
}
|
||||
528
kirby/src/Toolkit/Date.php
Normal file
528
kirby/src/Toolkit/Date.php
Normal file
@@ -0,0 +1,528 @@
|
||||
<?php
|
||||
|
||||
namespace Kirby\Toolkit;
|
||||
|
||||
use DateInterval;
|
||||
use DateTime;
|
||||
use DateTimeInterface;
|
||||
use DateTimeZone;
|
||||
use Exception;
|
||||
use IntlDateFormatter;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Extension for PHP's `DateTime` class
|
||||
* @since 3.6.2
|
||||
*
|
||||
* @package Kirby Toolkit
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>,
|
||||
* Lukas Bestle <lukas@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class Date extends DateTime
|
||||
{
|
||||
/**
|
||||
* Class constructor
|
||||
*
|
||||
* @param string|int|\DateTimeInterface $datetime Datetime string, UNIX timestamp or object
|
||||
* @param \DateTimeZone|null $timezone Optional default timezone if `$datetime` is string
|
||||
*/
|
||||
public function __construct(
|
||||
string|int|DateTimeInterface $datetime = 'now',
|
||||
DateTimeZone|null $timezone = null
|
||||
) {
|
||||
if (is_int($datetime) === true) {
|
||||
$datetime = date('r', $datetime);
|
||||
}
|
||||
|
||||
if ($datetime instanceof DateTimeInterface) {
|
||||
$datetime = $datetime->format('r');
|
||||
}
|
||||
|
||||
parent::__construct($datetime, $timezone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the datetime in `YYYY-MM-DD hh:mm:ss` format with timezone
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->toString('datetime');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds the datetime value up to next value of the specified unit
|
||||
*
|
||||
* @param string $unit `year`, `month`, `day`, `hour`, `minute` or `second`
|
||||
* @return $this
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the unit name is invalid
|
||||
*/
|
||||
public function ceil(string $unit): static
|
||||
{
|
||||
static::validateUnit($unit);
|
||||
|
||||
$this->floor($unit);
|
||||
$this->modify('+1 ' . $unit);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the interval between the provided and the object's datetime
|
||||
*
|
||||
* @param \DateTimeZone|null $timezone Optional default timezone if `$datetime` is string
|
||||
*/
|
||||
public function compare(
|
||||
string|int|DateTimeInterface $datetime = 'now',
|
||||
DateTimeZone|null $timezone = null
|
||||
): DateInterval {
|
||||
return $this->diff(new static($datetime, $timezone));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or sets the day value
|
||||
*/
|
||||
public function day(int|null $day = null): int
|
||||
{
|
||||
if ($day === null) {
|
||||
return (int)$this->format('d');
|
||||
}
|
||||
|
||||
$this->setDate($this->year(), $this->month(), $day);
|
||||
return $this->day();
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds the datetime value down to the specified unit
|
||||
*
|
||||
* @param string $unit `year`, `month`, `day`, `hour`, `minute` or `second`
|
||||
* @return $this
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the unit name is invalid
|
||||
*/
|
||||
public function floor(string $unit): static
|
||||
{
|
||||
static::validateUnit($unit);
|
||||
|
||||
$formats = [
|
||||
'year' => 'Y-01-01',
|
||||
'month' => 'Y-m-01',
|
||||
'day' => 'Y-m-d',
|
||||
'hour' => 'Y-m-d H:00:00',
|
||||
'minute' => 'Y-m-d H:i:00',
|
||||
'second' => 'Y-m-d H:i:s'
|
||||
];
|
||||
|
||||
$flooredDate = $this->format($formats[$unit]);
|
||||
$this->set($flooredDate, $this->timezone());
|
||||
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
|
||||
*/
|
||||
public function hour(int|null $hour = null): int
|
||||
{
|
||||
if ($hour === null) {
|
||||
return (int)$this->format('H');
|
||||
}
|
||||
|
||||
$this->setTime($hour, $this->minute());
|
||||
return $this->hour();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the object's datetime is the same as the given datetime
|
||||
*
|
||||
* @param \DateTimeZone|null $timezone Optional default timezone if `$datetime` is string
|
||||
*/
|
||||
public function is(
|
||||
string|int|DateTimeInterface $datetime = 'now',
|
||||
DateTimeZone|null $timezone = null
|
||||
): bool {
|
||||
return $this == new static($datetime, $timezone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the object's datetime is after the given datetime
|
||||
*
|
||||
* @param \DateTimeZone|null $timezone Optional default timezone if `$datetime` is string
|
||||
*/
|
||||
public function isAfter(
|
||||
string|int|DateTimeInterface $datetime = 'now',
|
||||
DateTimeZone|null $timezone = null
|
||||
): bool {
|
||||
return $this > new static($datetime, $timezone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the object's datetime is before the given datetime
|
||||
*
|
||||
* @param \DateTimeZone|null $timezone Optional default timezone if `$datetime` is string
|
||||
*/
|
||||
public function isBefore(
|
||||
string|int|DateTimeInterface $datetime = 'now',
|
||||
DateTimeZone|null $timezone = null
|
||||
): bool {
|
||||
return $this < new static($datetime, $timezone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the object's datetime is between the given datetimes
|
||||
*/
|
||||
public function isBetween(
|
||||
string|int|DateTimeInterface $min,
|
||||
string|int|DateTimeInterface $max
|
||||
): bool {
|
||||
return $this->isMin($min) === true && $this->isMax($max) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the object's datetime is at or before the given datetime
|
||||
*
|
||||
* @param \DateTimeZone|null $timezone Optional default timezone if `$datetime` is string
|
||||
*/
|
||||
public function isMax(
|
||||
string|int|DateTimeInterface $datetime = 'now',
|
||||
DateTimeZone|null $timezone = null
|
||||
): bool {
|
||||
return $this <= new static($datetime, $timezone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the object's datetime is at or after the given datetime
|
||||
*
|
||||
* @param \DateTimeZone|null $timezone Optional default timezone if `$datetime` is string
|
||||
*/
|
||||
public function isMin(
|
||||
string|int|DateTimeInterface $datetime = 'now',
|
||||
DateTimeZone|null $timezone = null
|
||||
): bool {
|
||||
return $this >= new static($datetime, $timezone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the microsecond value
|
||||
*/
|
||||
public function microsecond(): int
|
||||
{
|
||||
return (int)$this->format('u');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the millisecond value
|
||||
*/
|
||||
public function millisecond(): int
|
||||
{
|
||||
return (int)$this->format('v');
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or sets the minute value
|
||||
*/
|
||||
public function minute(int|null $minute = null): int
|
||||
{
|
||||
if ($minute === null) {
|
||||
return (int)$this->format('i');
|
||||
}
|
||||
|
||||
$this->setTime($this->hour(), $minute);
|
||||
return $this->minute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or sets the month value
|
||||
*/
|
||||
public function month(int|null $month = null): int
|
||||
{
|
||||
if ($month === null) {
|
||||
return (int)$this->format('m');
|
||||
}
|
||||
|
||||
$this->setDate($this->year(), $month, $this->day());
|
||||
return $this->month();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the datetime which is nearest to the object's datetime
|
||||
*
|
||||
* @param string|int|\DateTimeInterface ...$datetime Datetime strings, UNIX timestamps or objects
|
||||
*/
|
||||
public function nearest(
|
||||
string|int|DateTimeInterface ...$datetime
|
||||
): string|int|DateTimeInterface {
|
||||
$timestamp = $this->timestamp();
|
||||
$minDiff = PHP_INT_MAX;
|
||||
$nearest = null;
|
||||
|
||||
foreach ($datetime as $item) {
|
||||
$itemObject = new static($item, $this->timezone());
|
||||
$itemTimestamp = $itemObject->timestamp();
|
||||
$diff = abs($timestamp - $itemTimestamp);
|
||||
|
||||
if ($diff < $minDiff) {
|
||||
$minDiff = $diff;
|
||||
$nearest = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return $nearest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an instance of the current datetime
|
||||
*
|
||||
* @param \DateTimeZone|null $timezone
|
||||
*/
|
||||
public static function now(DateTimeZone|null $timezone = null): static
|
||||
{
|
||||
return new static('now', $timezone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to create an instance from the given string
|
||||
* or fails silently by returning `null` on error
|
||||
*/
|
||||
public static function optional(
|
||||
string|null $datetime = null,
|
||||
DateTimeZone|null $timezone = null
|
||||
): static|null {
|
||||
if (empty($datetime) === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return new static($datetime, $timezone);
|
||||
} catch (Exception) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds the date to the nearest value of the given unit
|
||||
*
|
||||
* @param string $unit `year`, `month`, `day`, `hour`, `minute` or `second`
|
||||
* @param int $size Rounding step starting at `0` of the specified unit
|
||||
* @return $this
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the unit name or size is invalid
|
||||
*/
|
||||
public function round(string $unit, int $size = 1): static
|
||||
{
|
||||
static::validateUnit($unit);
|
||||
|
||||
// round to a step of 1 first
|
||||
$floor = (clone $this)->floor($unit);
|
||||
$ceil = (clone $this)->ceil($unit);
|
||||
$nearest = $this->nearest($floor, $ceil);
|
||||
$this->set($nearest);
|
||||
|
||||
if ($size === 1) {
|
||||
// we are already done
|
||||
return $this;
|
||||
}
|
||||
|
||||
// validate step size
|
||||
if (
|
||||
in_array($unit, ['day', 'month', 'year']) && $size !== 1 ||
|
||||
$unit === 'hour' && 24 % $size !== 0 ||
|
||||
in_array($unit, ['second', 'minute']) && 60 % $size !== 0
|
||||
) {
|
||||
throw new InvalidArgumentException('Invalid rounding size for ' . $unit);
|
||||
}
|
||||
|
||||
// round to other rounding steps
|
||||
$value = $this->{$unit}();
|
||||
$value = round($value / $size) * $size;
|
||||
$this->{$unit}($value);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds the minutes of the given date
|
||||
* by the defined step
|
||||
* @since 3.7.0
|
||||
*
|
||||
* @param int|array|null $step array of `unit` and `size` to round to nearest
|
||||
*/
|
||||
public static function roundedTimestamp(
|
||||
string|null $date = null,
|
||||
int|array|null $step = null
|
||||
): int|null {
|
||||
if ($date = static::optional($date)) {
|
||||
if ($step !== null) {
|
||||
$step = static::stepConfig($step, [
|
||||
'unit' => 'minute',
|
||||
'size' => 1
|
||||
]);
|
||||
$date->round($step['unit'], $step['size']);
|
||||
}
|
||||
|
||||
return $date->timestamp();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or sets the second value
|
||||
*/
|
||||
public function second(int|null $second = null): int
|
||||
{
|
||||
if ($second === null) {
|
||||
return (int)$this->format('s');
|
||||
}
|
||||
|
||||
$this->setTime($this->hour(), $this->minute(), $second);
|
||||
return $this->second();
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrites the datetime value with a different one
|
||||
*
|
||||
* @param string|int|\DateTimeInterface $datetime Datetime string, UNIX timestamp or object
|
||||
* @param \DateTimeZone|null $timezone Optional default timezone if `$datetime` is string
|
||||
*/
|
||||
public function set(
|
||||
string|int|DateTimeInterface $datetime,
|
||||
DateTimeZone|null $timezone = null
|
||||
): void {
|
||||
$datetime = new static($datetime, $timezone);
|
||||
$this->setTimestamp($datetime->timestamp());
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the step configuration array for rounding
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
public static function stepConfig(
|
||||
// no type hint to use InvalidArgumentException at the end
|
||||
$input = null,
|
||||
array|null $default = ['size' => 1, 'unit' => 'day']
|
||||
): array {
|
||||
if ($input === null) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
if (is_array($input) === true) {
|
||||
$input = array_merge($default, $input);
|
||||
$input['unit'] = strtolower($input['unit']);
|
||||
return $input;
|
||||
}
|
||||
|
||||
if (is_int($input) === true) {
|
||||
return array_merge($default, ['size' => $input]);
|
||||
}
|
||||
|
||||
if (is_string($input) === true) {
|
||||
return array_merge($default, ['unit' => strtolower($input)]);
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Invalid input');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the time in `hh:mm:ss` format
|
||||
*/
|
||||
public function time(): string
|
||||
{
|
||||
return $this->format('H:i:s');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the UNIX timestamp
|
||||
*/
|
||||
public function timestamp(): int
|
||||
{
|
||||
return $this->getTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the timezone object
|
||||
*/
|
||||
public function timezone(): DateTimeZone|false
|
||||
{
|
||||
return $this->getTimezone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an instance of the beginning of the current day
|
||||
*/
|
||||
public static function today(DateTimeZone|null $timezone = null): static
|
||||
{
|
||||
return new static('today', $timezone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the date, time or datetime in `YYYY-MM-DD hh:mm:ss` format
|
||||
* with optional timezone
|
||||
*
|
||||
* @param string $mode `date`, `time` or `datetime`
|
||||
* @param bool $timezone Whether the timezone is printed as well
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the mode is invalid
|
||||
*/
|
||||
public function toString(
|
||||
string $mode = 'datetime',
|
||||
bool $timezone = true
|
||||
): string {
|
||||
$format = match ($mode) {
|
||||
'date' => 'Y-m-d',
|
||||
'time' => 'H:i:s',
|
||||
'datetime' => 'Y-m-d H:i:s',
|
||||
default => throw new InvalidArgumentException('Invalid mode')
|
||||
};
|
||||
|
||||
if ($timezone === true) {
|
||||
$format .= 'P';
|
||||
}
|
||||
|
||||
return $this->format($format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or sets the year value
|
||||
*/
|
||||
public function year(int|null $year = null): int
|
||||
{
|
||||
if ($year === null) {
|
||||
return (int)$this->format('Y');
|
||||
}
|
||||
|
||||
$this->setDate($year, $this->month(), $this->day());
|
||||
return $this->year();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that the provided string is a valid unit name
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException
|
||||
*/
|
||||
protected static function validateUnit(string $unit): void
|
||||
{
|
||||
$units = ['year', 'month', 'day', 'hour', 'minute', 'second'];
|
||||
if (in_array($unit, $units) === false) {
|
||||
throw new InvalidArgumentException('Invalid rounding unit');
|
||||
}
|
||||
}
|
||||
}
|
||||
932
kirby/src/Toolkit/Dom.php
Normal file
932
kirby/src/Toolkit/Dom.php
Normal file
@@ -0,0 +1,932 @@
|
||||
<?php
|
||||
|
||||
namespace Kirby\Toolkit;
|
||||
|
||||
use Closure;
|
||||
use DOMAttr;
|
||||
use DOMDocument;
|
||||
use DOMDocumentType;
|
||||
use DOMElement;
|
||||
use DOMNode;
|
||||
use DOMNodeList;
|
||||
use DOMProcessingInstruction;
|
||||
use DOMText;
|
||||
use DOMXPath;
|
||||
use Kirby\Cms\App;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Helper class for DOM handling using the DOMDocument class
|
||||
* @since 3.5.8
|
||||
*
|
||||
* @package Kirby Toolkit
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>,
|
||||
* Lukas Bestle <lukas@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class Dom
|
||||
{
|
||||
/**
|
||||
* Cache for the HTML body
|
||||
*
|
||||
* @var \DOMElement|null
|
||||
*/
|
||||
protected $body;
|
||||
|
||||
/**
|
||||
* The original input code as
|
||||
* passed to the constructor
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $code;
|
||||
|
||||
/**
|
||||
* Document object
|
||||
*
|
||||
* @var \DOMDocument
|
||||
*/
|
||||
protected $doc;
|
||||
|
||||
/**
|
||||
* Document type (`'HTML'` or `'XML'`)
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $type;
|
||||
|
||||
/**
|
||||
* Class constructor
|
||||
*
|
||||
* @param string $code XML or HTML code
|
||||
* @param string $type Document type (`'HTML'` or `'XML'`)
|
||||
*/
|
||||
public function __construct(string $code, string $type = 'HTML')
|
||||
{
|
||||
$this->code = $code;
|
||||
$this->doc = new DOMDocument();
|
||||
|
||||
$loaderSetting = null;
|
||||
|
||||
// switch to "user error handling"
|
||||
$intErrorsSetting = libxml_use_internal_errors(true);
|
||||
|
||||
$this->type = strtoupper($type);
|
||||
if ($this->type === 'HTML') {
|
||||
// ensure proper parsing for HTML snippets
|
||||
if (preg_match('/<(html|body)[> ]/i', $code) !== 1) {
|
||||
$code = '<body>' . $code . '</body>';
|
||||
}
|
||||
|
||||
// the loadHTML() method expects ISO-8859-1 by default;
|
||||
// force parsing as UTF-8 by injecting an XML declaration
|
||||
$xmlDeclaration = 'encoding="UTF-8" id="' . Str::random(10) . '"';
|
||||
$load = $this->doc->loadHTML('<?xml ' . $xmlDeclaration . '>' . $code);
|
||||
|
||||
// remove the injected XML declaration again
|
||||
$pis = $this->query('//processing-instruction()');
|
||||
foreach (iterator_to_array($pis, false) as $pi) {
|
||||
if ($pi->data === $xmlDeclaration) {
|
||||
static::remove($pi);
|
||||
}
|
||||
}
|
||||
|
||||
// remove the default doctype
|
||||
if (Str::contains($code, '<!DOCTYPE ', true) === false) {
|
||||
static::remove($this->doc->doctype);
|
||||
}
|
||||
} else {
|
||||
$load = $this->doc->loadXML($code);
|
||||
}
|
||||
|
||||
// get one error for use below and reset the global state
|
||||
$error = libxml_get_last_error();
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($intErrorsSetting);
|
||||
|
||||
if ($load !== true) {
|
||||
$message = 'The markup could not be parsed';
|
||||
|
||||
if ($error !== false) {
|
||||
$message .= ': ' . $error->message;
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException([
|
||||
'fallback' => $message,
|
||||
'details' => compact('error')
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the HTML body if one exists
|
||||
*/
|
||||
public function body(): DOMElement|null
|
||||
{
|
||||
return $this->body ??= $this->query('/html/body')[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the document object
|
||||
*/
|
||||
public function document(): DOMDocument
|
||||
{
|
||||
return $this->doc;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts all URLs wrapped in a url() wrapper. E.g. for style attributes.
|
||||
* @internal
|
||||
*/
|
||||
public static function extractUrls(string $value): array
|
||||
{
|
||||
// remove invisible ASCII characters from the value
|
||||
$value = trim(preg_replace('/[^ -~]/u', '', $value));
|
||||
|
||||
$count = preg_match_all(
|
||||
'!url\(\s*[\'"]?(.*?)[\'"]?\s*\)!i',
|
||||
$value,
|
||||
$matches,
|
||||
PREG_PATTERN_ORDER
|
||||
);
|
||||
|
||||
if (is_int($count) === true && $count > 0) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for allowed attributes according to the allowlist
|
||||
* @internal
|
||||
*
|
||||
* @return true|string If not allowed, an error message is returned
|
||||
*/
|
||||
public static function isAllowedAttr(
|
||||
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
|
||||
$isAllowedGlobalAttr = static::isAllowedGlobalAttr($attr, $options);
|
||||
|
||||
// no specific tag attribute list
|
||||
if (is_array($allowedTags) === false) {
|
||||
return $isAllowedGlobalAttr;
|
||||
}
|
||||
|
||||
// configuration per tag name
|
||||
$tagName = $attr->ownerElement->nodeName;
|
||||
$listedTagName = static::listContainsName(array_keys($options['allowedTags']), $attr->ownerElement, $options);
|
||||
$allowedAttrsForTag = $listedTagName ? ($allowedTags[$listedTagName] ?? true) : true;
|
||||
|
||||
// the element allows all global attributes
|
||||
if ($allowedAttrsForTag === true) {
|
||||
return $isAllowedGlobalAttr;
|
||||
}
|
||||
|
||||
// specific attributes are allowed in addition to the global ones
|
||||
if (is_array($allowedAttrsForTag) === true) {
|
||||
// if allowed globally, we don't need further checks
|
||||
if ($isAllowedGlobalAttr === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// otherwise the tag configuration decides
|
||||
if (static::listContainsName($allowedAttrsForTag, $attr, $options) !== false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return 'Not allowed by the "' . $tagName . '" element';
|
||||
}
|
||||
|
||||
return 'The "' . $tagName . '" element does not allow attributes';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for allowed attributes according to the global allowlist
|
||||
* @internal
|
||||
*
|
||||
* @return true|string If not allowed, an error message is returned
|
||||
*/
|
||||
public static function isAllowedGlobalAttr(
|
||||
DOMAttr $attr,
|
||||
array $options
|
||||
): bool|string {
|
||||
$options = static::normalizeSanitizeOptions($options);
|
||||
|
||||
$allowedAttrs = $options['allowedAttrs'];
|
||||
|
||||
if ($allowedAttrs === true) {
|
||||
// all attributes are allowed
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
static::listContainsName(
|
||||
$options['allowedAttrPrefixes'],
|
||||
$attr,
|
||||
$options,
|
||||
fn ($expected, $real): bool => Str::startsWith($real, $expected)
|
||||
) !== false
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
is_array($allowedAttrs) === true &&
|
||||
static::listContainsName($allowedAttrs, $attr, $options) !== false
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return 'Not included in the global allowlist';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the URL is acceptable for URL attributes
|
||||
* @internal
|
||||
*
|
||||
* @return true|string If not allowed, an error message is returned
|
||||
*/
|
||||
public static function isAllowedUrl(
|
||||
string $url,
|
||||
array $options
|
||||
): bool|string {
|
||||
$options = static::normalizeSanitizeOptions($options);
|
||||
|
||||
$url = Str::lower($url);
|
||||
|
||||
// allow empty URL values
|
||||
if (empty($url) === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// allow URLs that point to fragments inside the file
|
||||
if (mb_substr($url, 0, 1) === '#') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// disallow protocol-relative URLs
|
||||
if (mb_substr($url, 0, 2) === '//') {
|
||||
return 'Protocol-relative URLs are not allowed';
|
||||
}
|
||||
|
||||
// allow site-internal URLs that didn't match the
|
||||
// protocol-relative check above
|
||||
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)) {
|
||||
$indexUrl = $kirby->url('index', true)->path()->toString(true);
|
||||
|
||||
if (Str::startsWith($url, $indexUrl) !== true) {
|
||||
return 'The URL points outside of the site index URL';
|
||||
}
|
||||
|
||||
// disallow directory traversal outside of the index URL
|
||||
// TODO: the ../ sequences could be cleaned from the URL
|
||||
// before the check by normalizing the URL; then the
|
||||
// check above can also validate URLs with ../ sequences
|
||||
if (
|
||||
Str::contains($url, '../') !== false ||
|
||||
Str::contains($url, '..\\') !== false
|
||||
) {
|
||||
return 'The ../ sequence is not allowed in relative URLs';
|
||||
}
|
||||
}
|
||||
|
||||
// no active CMS instance, always allow site-internal URLs
|
||||
return true;
|
||||
}
|
||||
|
||||
// allow relative URLs (= URLs without a scheme);
|
||||
// this is either a URL without colon or one where the
|
||||
// part before the colon is definitely no valid scheme;
|
||||
// see https://url.spec.whatwg.org/#url-writing
|
||||
if (
|
||||
Str::contains($url, ':') === false ||
|
||||
Str::contains(Str::before($url, ':'), '/') === true
|
||||
) {
|
||||
// disallow directory traversal as we cannot know
|
||||
// in which URL context the URL will be printed
|
||||
if (
|
||||
Str::contains($url, '../') !== false ||
|
||||
Str::contains($url, '..\\') !== false
|
||||
) {
|
||||
return 'The ../ sequence is not allowed in relative URLs';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// allow specific HTTP(S) URLs
|
||||
if (
|
||||
Str::startsWith($url, 'http://') === true ||
|
||||
Str::startsWith($url, 'https://') === true
|
||||
) {
|
||||
if ($options['allowedDomains'] === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$hostname = parse_url($url, PHP_URL_HOST);
|
||||
|
||||
if (in_array($hostname, $options['allowedDomains']) === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return 'The hostname "' . $hostname . '" is not allowed';
|
||||
}
|
||||
|
||||
// allow listed data URIs
|
||||
if (Str::startsWith($url, 'data:') === true) {
|
||||
if ($options['allowedDataUris'] === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($options['allowedDataUris'] as $dataAttr) {
|
||||
if (Str::startsWith($url, $dataAttr) === true) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return 'Invalid data URI';
|
||||
}
|
||||
|
||||
// allow valid email addresses
|
||||
if (Str::startsWith($url, 'mailto:') === true) {
|
||||
$address = Str::after($url, 'mailto:');
|
||||
|
||||
if (empty($address) === true || V::email($address) === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return 'Invalid email address';
|
||||
}
|
||||
|
||||
// allow valid telephone numbers
|
||||
if (Str::startsWith($url, 'tel:') === true) {
|
||||
$address = Str::after($url, 'tel:');
|
||||
|
||||
if (
|
||||
empty($address) === true ||
|
||||
preg_match('!^[+]?[0-9]+$!', $address) === 1
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return 'Invalid telephone number';
|
||||
}
|
||||
|
||||
return 'Unknown URL type';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the XML extension is installed on the server.
|
||||
* Otherwise DOMDocument won't be available and the Dom cannot
|
||||
* work at all.
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public static function isSupported(): bool
|
||||
{
|
||||
return class_exists('DOMDocument') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the XML or HTML markup contained in the node
|
||||
*/
|
||||
public function innerMarkup(DOMNode $node): string
|
||||
{
|
||||
$markup = '';
|
||||
$method = 'save' . $this->type;
|
||||
|
||||
foreach ($node->childNodes as $child) {
|
||||
$markup .= $node->ownerDocument->$method($child);
|
||||
}
|
||||
|
||||
return $markup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a list contains the name of a node considering
|
||||
* the allowed namespaces
|
||||
* @internal
|
||||
*
|
||||
* @param array $options See `Dom::sanitize()`
|
||||
* @param \Closure|null Comparison callback that returns whether the expected and real name match
|
||||
* @return string|false Matched name in the list or `false`
|
||||
*/
|
||||
public static function listContainsName(
|
||||
array $list,
|
||||
DOMNode $node,
|
||||
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;
|
||||
|
||||
// if the configuration does not define namespace URIs or if the
|
||||
// currently checked node is from the special `xml:` namespace
|
||||
// that has a fixed namespace according to the XML spec...
|
||||
if ($allowedNamespaces === true || $node->namespaceURI === 'http://www.w3.org/XML/1998/namespace') {
|
||||
// ...take the list as it is and only consider
|
||||
// exact matches of the local name (which will
|
||||
// contain a namespace if that namespace name
|
||||
// is not defined in the document)
|
||||
|
||||
// the list contains the `xml:` prefix, so add it to the name as well
|
||||
if ($node->namespaceURI === 'http://www.w3.org/XML/1998/namespace') {
|
||||
$localName = 'xml:' . $localName;
|
||||
}
|
||||
|
||||
foreach ($list as $item) {
|
||||
if ($compare($item, $localName) === true) {
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// we need to consider the namespaces
|
||||
foreach ($list as $item) {
|
||||
// try to find the expected origin namespace URI
|
||||
$namespaceUri = null;
|
||||
$itemLocal = $item;
|
||||
if (Str::contains($item, ':') === true) {
|
||||
[$namespaceName, $itemLocal] = explode(':', $item);
|
||||
$namespaceUri = $allowedNamespaces[$namespaceName] ?? null;
|
||||
} else {
|
||||
// list items without namespace are from the default namespace
|
||||
$namespaceUri = $allowedNamespaces[''] ?? null;
|
||||
}
|
||||
|
||||
// try if we can find an exact namespaced match
|
||||
if (
|
||||
$namespaceUri === $node->namespaceURI &&
|
||||
$compare($itemLocal, $localName) === true
|
||||
) {
|
||||
return $item;
|
||||
}
|
||||
|
||||
// also try to match the fully-qualified name
|
||||
// if the document doesn't define the namespace
|
||||
if (
|
||||
$node->namespaceURI === null &&
|
||||
$compare($item, $node->nodeName) === true
|
||||
) {
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a node from the document
|
||||
*/
|
||||
public static function remove(DOMNode $node): void
|
||||
{
|
||||
$node->parentNode->removeChild($node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes an XPath query in the document
|
||||
*
|
||||
* @param \DOMNode|null $node Optional context node for relative queries
|
||||
*/
|
||||
public function query(
|
||||
string $query,
|
||||
DOMNode|null $node = null
|
||||
): DOMNodeList|false {
|
||||
return (new DOMXPath($this->doc))->query($query, $node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes the DOM according to the provided configuration
|
||||
*
|
||||
* @param array $options Array with the following options:
|
||||
* - `allowedAttrPrefixes`: Global list of allowed attribute prefixes
|
||||
* like `data-` and `aria-`
|
||||
* - `allowedAttrs`: Global list of allowed attrs or `true` to allow
|
||||
* any attribute
|
||||
* - `allowedDataUris`: List of all MIME types that may be used in
|
||||
* data URIs (only checked in `urlAttrs` and inside `url()` wrappers)
|
||||
* 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`
|
||||
* and `urlAttrs` lists; the namespace names as used in the document are *not*
|
||||
* validated; setting the whole option to `true` will allow any namespace
|
||||
* - `allowedPIs`: Names of allowed XML processing instructions or
|
||||
* `true` for any
|
||||
* - `allowedTags`: Associative array of all allowed tag names with the
|
||||
* value of either an array with the list of all allowed attributes for
|
||||
* this tag, `true` to allow any attribute from the `allowedAttrs` list
|
||||
* or `false` to allow the tag without any attributes;
|
||||
* not listed tags will be unwrapped (removed, but children are kept);
|
||||
* setting the whole option to `true` will allow any tag
|
||||
* - `attrCallback`: Closure that will receive each `DOMAttr` and may
|
||||
* modify it; the callback must return an array with exception
|
||||
* objects for each modification
|
||||
* - `disallowedTags`: Array of explicitly disallowed tags, which will
|
||||
* be removed completely including their children (matched case-insensitively)
|
||||
* - `doctypeCallback`: Closure that will receive the `DOMDocumentType`
|
||||
* and may throw exceptions on validation errors
|
||||
* - `elementCallback`: Closure that will receive each `DOMElement` and
|
||||
* may modify it; the callback must return an array with exception
|
||||
* objects for each modification
|
||||
* - `urlAttrs`: List of attributes that may contain URLs
|
||||
* @return array List of validation errors during sanitization
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the doctype is not valid
|
||||
*/
|
||||
public function sanitize(array $options): array
|
||||
{
|
||||
$options = static::normalizeSanitizeOptions($options);
|
||||
|
||||
$errors = [];
|
||||
|
||||
// validate the doctype;
|
||||
// convert the `DOMNodeList` to an array first, otherwise removing
|
||||
// nodes would shift the list and make subsequent operations fail
|
||||
foreach (iterator_to_array($this->doc->childNodes, false) as $child) {
|
||||
if ($child instanceof DOMDocumentType) {
|
||||
$this->sanitizeDoctype($child, $options, $errors);
|
||||
}
|
||||
}
|
||||
|
||||
// validate all processing instructions like <?xml-stylesheet
|
||||
$pis = $this->query('//processing-instruction()');
|
||||
foreach (iterator_to_array($pis, false) as $pi) {
|
||||
$this->sanitizePI($pi, $options, $errors);
|
||||
}
|
||||
|
||||
// validate all elements in the document tree
|
||||
$elements = $this->doc->getElementsByTagName('*');
|
||||
foreach (iterator_to_array($elements, false) as $element) {
|
||||
$this->sanitizeElement($element, $options, $errors);
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the document markup as string
|
||||
*
|
||||
* @param bool $normalize If set to `true`, the document
|
||||
* is exported with an XML declaration/
|
||||
* full HTML markup even if the input
|
||||
* didn't have them
|
||||
*/
|
||||
public function toString(bool $normalize = false): string
|
||||
{
|
||||
if ($this->type === 'HTML') {
|
||||
$string = $this->exportHtml($normalize);
|
||||
} else {
|
||||
$string = $this->exportXml($normalize);
|
||||
}
|
||||
|
||||
// add trailing newline if the input contained one
|
||||
if (rtrim($this->code, "\r\n") !== $this->code) {
|
||||
$string .= "\n";
|
||||
}
|
||||
|
||||
return $string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a node from the document but keeps its children
|
||||
* by moving them one level up
|
||||
*/
|
||||
public static function unwrap(DOMNode $node): void
|
||||
{
|
||||
foreach ($node->childNodes as $childNode) {
|
||||
// discard text nodes as they can be unexpected
|
||||
// directly in the parent element
|
||||
if ($childNode instanceof DOMText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$node->parentNode->insertBefore(clone $childNode, $node);
|
||||
}
|
||||
|
||||
static::remove($node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the document markup as HTML string
|
||||
*
|
||||
* @param bool $normalize If set to `true`, the document
|
||||
* is exported with full HTML markup
|
||||
* even if the input didn't have it
|
||||
*/
|
||||
protected function exportHtml(bool $normalize = false): string
|
||||
{
|
||||
// enforce export as UTF-8 by injecting a <meta> tag
|
||||
// at the beginning of the document
|
||||
$metaTag = $this->doc->createElement('meta');
|
||||
$metaTag->setAttribute('http-equiv', 'Content-Type');
|
||||
$metaTag->setAttribute('content', 'text/html; charset=utf-8');
|
||||
$metaTag->setAttribute('id', Str::random(10));
|
||||
$this->doc->insertBefore($metaTag, $this->doc->documentElement);
|
||||
|
||||
if (
|
||||
preg_match('/<html[> ]/i', $this->code) === 1 ||
|
||||
$this->doc->doctype !== null ||
|
||||
$normalize === true
|
||||
) {
|
||||
// full document
|
||||
$html = $this->doc->saveHTML();
|
||||
} elseif (preg_match('/<body[> ]/i', $this->code) === 1) {
|
||||
// there was a <body>, but no <html>; export just the <body>
|
||||
$html = $this->doc->saveHTML($this->body());
|
||||
} else {
|
||||
// just an HTML snippet
|
||||
$html = $this->innerMarkup($this->body());
|
||||
}
|
||||
|
||||
// remove the <meta> tag from the document and from the output
|
||||
static::remove($metaTag);
|
||||
$html = str_replace($this->doc->saveHTML($metaTag), '', $html);
|
||||
|
||||
return trim($html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the document markup as XML string
|
||||
*
|
||||
* @param bool $normalize If set to `true`, the document
|
||||
* is exported with an XML declaration
|
||||
* even if the input didn't have it
|
||||
*/
|
||||
protected function exportXml(bool $normalize = false): string
|
||||
{
|
||||
if (
|
||||
Str::contains($this->code, '<?xml ', true) === false &&
|
||||
$normalize === false
|
||||
) {
|
||||
// the input didn't contain an XML declaration;
|
||||
// only return child nodes, which omits it
|
||||
$result = [];
|
||||
foreach ($this->doc->childNodes as $node) {
|
||||
$result[] = $this->doc->saveXML($node);
|
||||
}
|
||||
|
||||
return implode("\n", $result);
|
||||
}
|
||||
|
||||
// ensure that the document is encoded as UTF-8
|
||||
// unless a different encoding was specified in
|
||||
// the input or before exporting
|
||||
$this->doc->encoding ??= 'UTF-8';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return [
|
||||
'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,
|
||||
'_normalized' => true
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes an attribute
|
||||
*
|
||||
* @param array $options See `Dom::sanitize()`
|
||||
* @param array $errors Array to store additional errors in by reference
|
||||
*/
|
||||
protected function sanitizeAttr(
|
||||
DOMAttr $attr,
|
||||
array $options,
|
||||
array &$errors
|
||||
): void {
|
||||
$element = $attr->ownerElement;
|
||||
$name = $attr->nodeName;
|
||||
$value = $attr->value;
|
||||
|
||||
$allowed = static::isAllowedAttr($attr, $options);
|
||||
if ($allowed !== true) {
|
||||
$errors[] = new InvalidArgumentException(
|
||||
'The "' . $name . '" attribute (line ' .
|
||||
$attr->getLineNo() . ') is not allowed: ' .
|
||||
$allowed
|
||||
);
|
||||
$element->removeAttributeNode($attr);
|
||||
} elseif (static::listContainsName($options['urlAttrs'], $attr, $options) !== false) {
|
||||
$allowed = static::isAllowedUrl($value, $options);
|
||||
if ($allowed !== true) {
|
||||
$errors[] = new InvalidArgumentException(
|
||||
'The URL is not allowed in attribute "' .
|
||||
$name . '" (line ' . $attr->getLineNo() . '): ' .
|
||||
$allowed
|
||||
);
|
||||
$element->removeAttributeNode($attr);
|
||||
}
|
||||
} else {
|
||||
// check for unwanted URLs in other attributes
|
||||
foreach (static::extractUrls($value) as $url) {
|
||||
$allowed = static::isAllowedUrl($url, $options);
|
||||
if ($allowed !== true) {
|
||||
$errors[] = new InvalidArgumentException(
|
||||
'The URL is not allowed in attribute "' .
|
||||
$name . '" (line ' . $attr->getLineNo() . '): ' .
|
||||
$allowed
|
||||
);
|
||||
$element->removeAttributeNode($attr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes the doctype
|
||||
*
|
||||
* @param array $options See `Dom::sanitize()`
|
||||
* @param array $errors Array to store additional errors in by reference
|
||||
*/
|
||||
protected function sanitizeDoctype(
|
||||
DOMDocumentType $doctype,
|
||||
array $options,
|
||||
array &$errors
|
||||
): void {
|
||||
try {
|
||||
$this->validateDoctype($doctype, $options);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$errors[] = $e;
|
||||
static::remove($doctype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a single DOM element and its attribute
|
||||
*
|
||||
* @param array $options See `Dom::sanitize()`
|
||||
* @param array $errors Array to store additional errors in by reference
|
||||
*/
|
||||
protected function sanitizeElement(
|
||||
DOMElement $element,
|
||||
array $options,
|
||||
array &$errors
|
||||
): void {
|
||||
$name = $element->nodeName;
|
||||
|
||||
// check defined namespaces (`xmlns` attributes);
|
||||
// we need to check this first as the namespace can affect
|
||||
// whether the tag name is valid according to the configuration
|
||||
if (is_array($options['allowedNamespaces']) === true) {
|
||||
$simpleXmlElement = simplexml_import_dom($element);
|
||||
foreach ($simpleXmlElement->getDocNamespaces(false, false) as $namespace => $value) {
|
||||
if (array_search($value, $options['allowedNamespaces']) === false) {
|
||||
$element->removeAttributeNS($value, $namespace);
|
||||
$errors[] = new InvalidArgumentException(
|
||||
'The namespace "' . $value . '" is not allowed' .
|
||||
' (around line ' . $element->getLineNo() . ')'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check if the tag is blocklisted; remove the element completely
|
||||
if (
|
||||
static::listContainsName(
|
||||
$options['disallowedTags'],
|
||||
$element,
|
||||
$options,
|
||||
fn ($expected, $real): bool => Str::lower($expected) === Str::lower($real)
|
||||
) !== false
|
||||
) {
|
||||
$errors[] = new InvalidArgumentException(
|
||||
'The "' . $name . '" element (line ' .
|
||||
$element->getLineNo() . ') is not allowed'
|
||||
);
|
||||
static::remove($element);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// check if the tag is not allowlisted; keep children
|
||||
if ($options['allowedTags'] !== true) {
|
||||
$listedName = static::listContainsName(array_keys($options['allowedTags']), $element, $options);
|
||||
|
||||
if ($listedName === false) {
|
||||
$errors[] = new InvalidArgumentException(
|
||||
'The "' . $name . '" element (line ' .
|
||||
$element->getLineNo() . ') is not allowed, ' .
|
||||
'but its children can be kept'
|
||||
);
|
||||
static::unwrap($element);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// check attributes
|
||||
if ($element->hasAttributes()) {
|
||||
// convert the `DOMNodeList` to an array first, otherwise removing
|
||||
// attributes would shift the list and make subsequent operations fail
|
||||
foreach (iterator_to_array($element->attributes, false) as $attr) {
|
||||
$this->sanitizeAttr($attr, $options, $errors);
|
||||
|
||||
// custom check (if the attribute is still in the document)
|
||||
if ($attr->ownerElement !== null && $options['attrCallback']) {
|
||||
$errors = array_merge($errors, $options['attrCallback']($attr, $options) ?? []);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// custom check
|
||||
if ($options['elementCallback']) {
|
||||
$errors = array_merge($errors, $options['elementCallback']($element, $options) ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a single XML processing instruction
|
||||
*
|
||||
* @param array $options See `Dom::sanitize()`
|
||||
* @param array $errors Array to store additional errors in by reference
|
||||
*/
|
||||
protected function sanitizePI(
|
||||
DOMProcessingInstruction $pi,
|
||||
array $options,
|
||||
array &$errors
|
||||
): void {
|
||||
$name = $pi->nodeName;
|
||||
|
||||
// check for allow-listed processing instructions
|
||||
if (is_array($options['allowedPIs']) === true && in_array($name, $options['allowedPIs']) === false) {
|
||||
$errors[] = new InvalidArgumentException(
|
||||
'The "' . $name . '" processing instruction (line ' .
|
||||
$pi->getLineNo() . ') is not allowed'
|
||||
);
|
||||
static::remove($pi);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the document type
|
||||
*
|
||||
* @param array $options See `Dom::sanitize()`
|
||||
*
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the doctype is not valid
|
||||
*/
|
||||
protected function validateDoctype(
|
||||
DOMDocumentType $doctype,
|
||||
array $options
|
||||
): void {
|
||||
if (
|
||||
empty($doctype->publicId) === false ||
|
||||
empty($doctype->systemId) === false
|
||||
) {
|
||||
throw new InvalidArgumentException('The doctype must not reference external files');
|
||||
}
|
||||
|
||||
if (empty($doctype->internalSubset) === false) {
|
||||
throw new InvalidArgumentException('The doctype must not define a subset');
|
||||
}
|
||||
|
||||
if ($options['doctypeCallback']) {
|
||||
$options['doctypeCallback']($doctype, $options);
|
||||
}
|
||||
}
|
||||
}
|
||||
140
kirby/src/Toolkit/Escape.php
Normal file
140
kirby/src/Toolkit/Escape.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace Kirby\Toolkit;
|
||||
|
||||
use Laminas\Escaper\Escaper;
|
||||
|
||||
/**
|
||||
* The `Escape` class provides methods
|
||||
* for escaping common HTML attributes
|
||||
* data. This can be used to put
|
||||
* untrusted data into typical
|
||||
* attribute values like width, name,
|
||||
* value, etc.
|
||||
*
|
||||
* Wrapper for the Laminas Escaper
|
||||
* @link https://github.com/laminas/laminas-escaper
|
||||
*
|
||||
* @package Kirby Toolkit
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class Escape
|
||||
{
|
||||
/**
|
||||
* The internal singleton escaper instance
|
||||
*/
|
||||
protected static Escaper|null $escaper = null;
|
||||
|
||||
/**
|
||||
* Escape common HTML attributes data
|
||||
*
|
||||
* This can be used to put untrusted data into typical attribute values
|
||||
* like width, name, value, etc.
|
||||
*
|
||||
* This should not be used for complex attributes like href, src, style,
|
||||
* or any of the event handlers like onmouseover.
|
||||
* Use esc($string, 'js') for event handler attributes, esc($string, 'url')
|
||||
* for src attributes and esc($string, 'css') for style attributes.
|
||||
*
|
||||
* <div attr=...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...>content</div>
|
||||
* <div attr='...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...'>content</div>
|
||||
* <div attr="...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...">content</div>
|
||||
*/
|
||||
public static function attr(string $string): string
|
||||
{
|
||||
return static::escaper()->escapeHtmlAttr($string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML style property values
|
||||
*
|
||||
* This can be used to put untrusted data into a stylesheet or a style tag.
|
||||
*
|
||||
* Stay away from putting untrusted data into complex properties like url,
|
||||
* behavior, and custom (-moz-binding). You should also not put untrusted data
|
||||
* into IE’s expression property value which allows JavaScript.
|
||||
*
|
||||
* <style>selector { property : ...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...; } </style>
|
||||
* <style>selector { property : "...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE..."; } </style>
|
||||
* <span style="property : ...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...">text</span>
|
||||
*/
|
||||
public static function css(string $string): string
|
||||
{
|
||||
return static::escaper()->escapeCss($string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the escaper instance (and create if needed)
|
||||
*/
|
||||
protected static function escaper(): Escaper
|
||||
{
|
||||
return static::$escaper ??= new Escaper('utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML element content
|
||||
*
|
||||
* This can be used to put untrusted data directly into the HTML body somewhere.
|
||||
* This includes inside normal tags like div, p, b, td, etc.
|
||||
*
|
||||
* Escapes &, <, >, ", and ' with HTML entity encoding to prevent switching
|
||||
* into any execution context, such as script, style, or event handlers.
|
||||
*
|
||||
* <body>...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...</body>
|
||||
* <div>...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...</div>
|
||||
*/
|
||||
public static function html(string $string): string
|
||||
{
|
||||
return static::escaper()->escapeHtml($string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape JavaScript data values
|
||||
*
|
||||
* This can be used to put dynamically generated JavaScript code
|
||||
* into both script blocks and event-handler attributes.
|
||||
*
|
||||
* <script>alert('...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...')</script>
|
||||
* <script>x='...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...'</script>
|
||||
* <div onmouseover="x='...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...'"</div>
|
||||
*/
|
||||
public static function js(string $string): string
|
||||
{
|
||||
return static::escaper()->escapeJs($string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape URL parameter values
|
||||
*
|
||||
* This can be used to put untrusted data into HTTP GET parameter values.
|
||||
* This should not be used to escape an entire URI.
|
||||
*
|
||||
* <a href="http://www.somesite.com?test=...ESCAPE UNTRUSTED DATA BEFORE PUTTING HERE...">link</a>
|
||||
*/
|
||||
public static function url(string $string): string
|
||||
{
|
||||
return rawurlencode($string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape XML element content
|
||||
*
|
||||
* Removes offending characters that could be wrongfully interpreted as XML markup.
|
||||
*
|
||||
* The following characters are reserved in XML and will be replaced with their
|
||||
* corresponding XML entities:
|
||||
*
|
||||
* ' is replaced with '
|
||||
* " is replaced with "
|
||||
* & is replaced with &
|
||||
* < is replaced with <
|
||||
* > is replaced with >
|
||||
*/
|
||||
public static function xml(string $string): string
|
||||
{
|
||||
return htmlspecialchars($string, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||||
}
|
||||
}
|
||||
30
kirby/src/Toolkit/Facade.php
Normal file
30
kirby/src/Toolkit/Facade.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace Kirby\Toolkit;
|
||||
|
||||
/**
|
||||
* Laravel-style static facades
|
||||
* for class instances
|
||||
*
|
||||
* @package Kirby Toolkit
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
abstract class Facade
|
||||
{
|
||||
/**
|
||||
* Returns the instance that should be
|
||||
* available statically
|
||||
*/
|
||||
abstract public static function instance();
|
||||
|
||||
/**
|
||||
* Proxy for all public instance calls
|
||||
*/
|
||||
public static function __callStatic(string $method, array $args = null)
|
||||
{
|
||||
return static::instance()->$method(...$args);
|
||||
}
|
||||
}
|
||||
658
kirby/src/Toolkit/Html.php
Normal file
658
kirby/src/Toolkit/Html.php
Normal file
@@ -0,0 +1,658 @@
|
||||
<?php
|
||||
|
||||
namespace Kirby\Toolkit;
|
||||
|
||||
use Kirby\Filesystem\F;
|
||||
use Kirby\Http\Uri;
|
||||
use Kirby\Http\Url;
|
||||
|
||||
/**
|
||||
* HTML builder for the most common elements
|
||||
*
|
||||
* @package Kirby Toolkit
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class Html extends Xml
|
||||
{
|
||||
/**
|
||||
* An internal store for an HTML entities translation table
|
||||
*/
|
||||
public static array|null $entities = null;
|
||||
|
||||
/**
|
||||
* List of HTML tags that can be used inline
|
||||
*/
|
||||
public static array $inlineList = [
|
||||
'b',
|
||||
'i',
|
||||
'small',
|
||||
'abbr',
|
||||
'cite',
|
||||
'code',
|
||||
'dfn',
|
||||
'em',
|
||||
'kbd',
|
||||
'strong',
|
||||
'samp',
|
||||
'var',
|
||||
'a',
|
||||
'bdo',
|
||||
'br',
|
||||
'img',
|
||||
'q',
|
||||
'span',
|
||||
'sub',
|
||||
'sup'
|
||||
];
|
||||
|
||||
/**
|
||||
* Closing string for void tags;
|
||||
* can be used to switch to trailing slashes if required
|
||||
*
|
||||
* ```php
|
||||
* Html::$void = ' />'
|
||||
* ```
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public static $void = '>';
|
||||
|
||||
/**
|
||||
* List of HTML tags that are considered to be self-closing
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
public static $voidList = [
|
||||
'area',
|
||||
'base',
|
||||
'br',
|
||||
'col',
|
||||
'command',
|
||||
'embed',
|
||||
'hr',
|
||||
'img',
|
||||
'input',
|
||||
'keygen',
|
||||
'link',
|
||||
'meta',
|
||||
'param',
|
||||
'source',
|
||||
'track',
|
||||
'wbr'
|
||||
];
|
||||
|
||||
/**
|
||||
* Generic HTML tag generator
|
||||
* Can be called like `Html::p('A paragraph', ['class' => 'text'])`
|
||||
*
|
||||
* @param string $tag Tag name
|
||||
* @param array $arguments Further arguments for the Html::tag() method
|
||||
*/
|
||||
public static function __callStatic(
|
||||
string $tag,
|
||||
array $arguments = []
|
||||
): string {
|
||||
if (static::isVoid($tag) === true) {
|
||||
return static::tag($tag, null, ...$arguments);
|
||||
}
|
||||
|
||||
return static::tag($tag, ...$arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an `<a>` tag; automatically supports mailto: and tel: links
|
||||
*
|
||||
* @param string $href The URL for the `<a>` tag
|
||||
* @param string|array|null $text The optional text; if `null`, the URL will be used as text
|
||||
* @param array $attr Additional attributes for the tag
|
||||
* @return string The generated HTML
|
||||
*/
|
||||
public static function a(string $href, $text = null, array $attr = []): string
|
||||
{
|
||||
if (Str::startsWith($href, 'mailto:')) {
|
||||
return static::email(substr($href, 7), $text, $attr);
|
||||
}
|
||||
|
||||
if (Str::startsWith($href, 'tel:')) {
|
||||
return static::tel(substr($href, 4), $text, $attr);
|
||||
}
|
||||
|
||||
return static::link($href, $text, $attr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a single attribute or a list of attributes
|
||||
*
|
||||
* @param string|array $name String: A single attribute with that name will be generated.
|
||||
* Key-value array: A list of attributes will be generated. Don't pass a second argument in that case.
|
||||
* @param mixed $value If used with a `$name` string, pass the value of the attribute here.
|
||||
* If used with a `$name` array, this can be set to `false` to disable attribute sorting.
|
||||
* @param string|null $before An optional string that will be prepended if the result is not empty
|
||||
* @param string|null $after An optional string that will be appended if the result is not empty
|
||||
* @return string|null The generated HTML attributes string
|
||||
*/
|
||||
public static function attr(
|
||||
string|array $name,
|
||||
$value = null,
|
||||
string|null $before = null,
|
||||
string|null $after = null
|
||||
): string|null {
|
||||
// HTML supports boolean attributes without values
|
||||
if (is_array($name) === false && is_bool($value) === true) {
|
||||
return $value === true ? strtolower($name) : null;
|
||||
}
|
||||
|
||||
// HTML attribute names are case-insensitive
|
||||
if (is_string($name) === true) {
|
||||
$name = strtolower($name);
|
||||
}
|
||||
|
||||
// all other cases can share the XML variant
|
||||
$attr = parent::attr($name, $value);
|
||||
|
||||
if ($attr === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// HTML supports named entities
|
||||
$entities = parent::entities();
|
||||
$html = array_keys($entities);
|
||||
$xml = array_values($entities);
|
||||
$attr = str_replace($xml, $html, $attr);
|
||||
|
||||
if ($attr) {
|
||||
return $before . $attr . $after;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts lines in a string into HTML breaks
|
||||
*/
|
||||
public static function breaks(string $string): string
|
||||
{
|
||||
return nl2br($string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an `<a>` tag with `mailto:`
|
||||
*
|
||||
* @param string $email The email address
|
||||
* @param string|array|null $text The optional text; if `null`, the email address will be used as text
|
||||
* @param array $attr Additional attributes for the tag
|
||||
* @return string The generated HTML
|
||||
*/
|
||||
public static function email(
|
||||
string $email,
|
||||
string|array|null $text = null,
|
||||
array $attr = []
|
||||
): string {
|
||||
if (empty($email) === true) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (empty($text) === true) {
|
||||
// show only the email address without additional parameters
|
||||
$address = Str::contains($email, '?') ? Str::before($email, '?') : $email;
|
||||
|
||||
$text = [Str::encode($address)];
|
||||
}
|
||||
|
||||
$email = Str::encode($email);
|
||||
$attr = array_merge([
|
||||
'href' => [
|
||||
'value' => 'mailto:' . $email,
|
||||
'escape' => false
|
||||
]
|
||||
], $attr);
|
||||
|
||||
// add rel=noopener to target blank links to improve security
|
||||
$attr['rel'] = static::rel($attr['rel'] ?? null, $attr['target'] ?? null);
|
||||
|
||||
return static::tag('a', $text, $attr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a string to an HTML-safe string
|
||||
*
|
||||
* @param bool $keepTags If true, existing tags won't be escaped
|
||||
* @return string The HTML string
|
||||
*
|
||||
* @psalm-suppress ParamNameMismatch
|
||||
*/
|
||||
public static function encode(
|
||||
string|null $string,
|
||||
bool $keepTags = false
|
||||
): string {
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($keepTags === true) {
|
||||
$list = static::entities();
|
||||
unset($list['"'], $list['<'], $list['>'], $list['&']);
|
||||
|
||||
$search = array_keys($list);
|
||||
$values = array_values($list);
|
||||
|
||||
return str_replace($search, $values, $string);
|
||||
}
|
||||
|
||||
return htmlentities($string, ENT_QUOTES, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the entity translation table
|
||||
*/
|
||||
public static function entities(): array
|
||||
{
|
||||
return self::$entities ??= get_html_translation_table(HTML_ENTITIES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a `<figure>` tag with optional caption
|
||||
*
|
||||
* @param string|array $content Contents of the `<figure>` tag
|
||||
* @param string|array $caption Optional `<figcaption>` text to use
|
||||
* @param array $attr Additional attributes for the `<figure>` tag
|
||||
* @return string The generated HTML
|
||||
*/
|
||||
public static function figure(
|
||||
string|array $content,
|
||||
string|array|null $caption = '',
|
||||
array $attr = []
|
||||
): string {
|
||||
if ($caption) {
|
||||
$figcaption = static::tag('figcaption', $caption);
|
||||
|
||||
if (is_string($content) === true) {
|
||||
$content = [static::encode($content, false)];
|
||||
}
|
||||
|
||||
$content[] = $figcaption;
|
||||
}
|
||||
|
||||
return static::tag('figure', $content, $attr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Embeds a GitHub Gist
|
||||
*
|
||||
* @param string $url Gist URL
|
||||
* @param string|null $file Optional specific file to embed
|
||||
* @param array $attr Additional attributes for the `<script>` tag
|
||||
* @return string The generated HTML
|
||||
*/
|
||||
public static function gist(
|
||||
string $url,
|
||||
string|null $file = null,
|
||||
array $attr = []
|
||||
): string {
|
||||
$src = $url . '.js';
|
||||
|
||||
if ($file !== null) {
|
||||
$src .= '?file=' . $file;
|
||||
}
|
||||
|
||||
return static::tag('script', '', array_merge($attr, ['src' => $src]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an `<iframe>`
|
||||
*
|
||||
* @param array $attr Additional attributes for the `<iframe>` tag
|
||||
* @return string The generated HTML
|
||||
*/
|
||||
public static function iframe(string $src, array $attr = []): string
|
||||
{
|
||||
return static::tag('iframe', '', array_merge(['src' => $src], $attr));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an `<img>` tag
|
||||
*
|
||||
* @param string $src The URL of the image
|
||||
* @param array $attr Additional attributes for the `<img>` tag
|
||||
* @return string The generated HTML
|
||||
*/
|
||||
public static function img(string $src, array $attr = []): string
|
||||
{
|
||||
$attr = array_merge([
|
||||
'src' => $src,
|
||||
'alt' => ''
|
||||
], $attr);
|
||||
|
||||
return static::tag('img', '', $attr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a tag is self-closing
|
||||
*/
|
||||
public static function isVoid(string $tag): bool
|
||||
{
|
||||
return in_array(strtolower($tag), static::$voidList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an `<a>` link tag (without automatic email: and tel: detection)
|
||||
*
|
||||
* @param string $href The URL for the `<a>` tag
|
||||
* @param string|array|null $text The optional text; if `null`, the URL will be used as text
|
||||
* @param array $attr Additional attributes for the tag
|
||||
* @return string The generated HTML
|
||||
*/
|
||||
public static function link(
|
||||
string $href,
|
||||
string|array|null $text = null,
|
||||
array $attr = []
|
||||
): string {
|
||||
$attr = array_merge(['href' => $href], $attr);
|
||||
|
||||
if (empty($text) === true) {
|
||||
$text = $attr['href'];
|
||||
}
|
||||
|
||||
if (is_string($text) === true && V::url($text) === true) {
|
||||
$text = Url::short($text);
|
||||
}
|
||||
|
||||
// add rel=noopener to target blank links to improve security
|
||||
$attr['rel'] = static::rel($attr['rel'] ?? null, $attr['target'] ?? null);
|
||||
|
||||
return static::tag('a', $text, $attr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add noreferrer to rels when target is `_blank`
|
||||
*
|
||||
* @param string|null $rel Current `rel` value
|
||||
* @param string|null $target Current `target` value
|
||||
* @return string|null New `rel` value or `null` if not needed
|
||||
*/
|
||||
public static function rel(
|
||||
string|null $rel = null,
|
||||
string|null $target = null
|
||||
): string|null {
|
||||
$rel = trim($rel ?? '');
|
||||
|
||||
if ($target === '_blank') {
|
||||
if (empty($rel) === false) {
|
||||
return $rel;
|
||||
}
|
||||
|
||||
return trim($rel . ' noreferrer', ' ');
|
||||
}
|
||||
|
||||
return $rel ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an HTML tag
|
||||
*
|
||||
* @param string $name Tag name
|
||||
* @param array|string $content Scalar value or array with multiple lines of content; self-closing
|
||||
* tags are generated automatically based on the `Html::isVoid()` list
|
||||
* @param array $attr An associative array with additional attributes for the tag
|
||||
* @param string|null $indent Indentation string, defaults to two spaces or `null` for output on one line
|
||||
* @param int $level Indentation level
|
||||
* @return string The generated HTML
|
||||
*/
|
||||
public static function tag(
|
||||
string $name,
|
||||
array|string|null $content = '',
|
||||
array $attr = [],
|
||||
string $indent = null,
|
||||
int $level = 0
|
||||
): string {
|
||||
// treat an explicit `null` value as an empty tag
|
||||
// as void tags are already covered below
|
||||
$content ??= '';
|
||||
|
||||
// force void elements to be self-closing
|
||||
if (static::isVoid($name) === true) {
|
||||
$content = null;
|
||||
}
|
||||
|
||||
return parent::tag($name, $content, $attr, $indent, $level);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an `<a>` tag for a phone number
|
||||
*
|
||||
* @param string $tel The phone number
|
||||
* @param string|array|null $text The optional text; if `null`, the phone number will be used as text
|
||||
* @param array $attr Additional attributes for the tag
|
||||
* @return string The generated HTML
|
||||
*/
|
||||
public static function tel(
|
||||
string $tel,
|
||||
string|array|null $text = null,
|
||||
array $attr = []
|
||||
): string {
|
||||
$number = preg_replace('![^0-9\+]+!', '', $tel);
|
||||
|
||||
if (empty($text) === true) {
|
||||
$text = $tel;
|
||||
}
|
||||
|
||||
return static::link('tel:' . $number, $text, $attr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Properly encodes tag contents
|
||||
*/
|
||||
public static function value($value): string|null
|
||||
{
|
||||
if ($value === true) {
|
||||
return 'true';
|
||||
}
|
||||
|
||||
if ($value === false) {
|
||||
return 'false';
|
||||
}
|
||||
|
||||
if (is_numeric($value) === true) {
|
||||
return (string)$value;
|
||||
}
|
||||
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return static::encode($value, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a video embed via `<iframe>` for YouTube or Vimeo
|
||||
* videos; the embed URLs are automatically detected from
|
||||
* the given URL
|
||||
*
|
||||
* @param string $url Video URL
|
||||
* @param array $options Additional `vimeo` and `youtube` options
|
||||
* (will be used as query params in the embed URL)
|
||||
* @param array $attr Additional attributes for the `<iframe>` tag
|
||||
* @return string|null The generated HTML
|
||||
*/
|
||||
public static function video(
|
||||
string $url,
|
||||
array $options = [],
|
||||
array $attr = []
|
||||
): string|null {
|
||||
// YouTube video
|
||||
if (Str::contains($url, 'youtu', true) === true) {
|
||||
return static::youtube($url, $options['youtube'] ?? [], $attr);
|
||||
}
|
||||
|
||||
// Vimeo video
|
||||
if (Str::contains($url, 'vimeo', true) === true) {
|
||||
return static::vimeo($url, $options['vimeo'] ?? [], $attr);
|
||||
}
|
||||
|
||||
// self-hosted video file
|
||||
$extension = F::extension($url);
|
||||
$type = F::extensionToType($extension);
|
||||
$mime = F::extensionToMime($extension);
|
||||
|
||||
// ignore unknown file types
|
||||
if ($type !== 'video') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return static::tag('video', [
|
||||
static::tag('source', null, [
|
||||
'src' => $url,
|
||||
'type' => $mime
|
||||
])
|
||||
], $attr);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a list of attributes
|
||||
* for video iframes
|
||||
*/
|
||||
public static function videoAttr(array $attr = []): array
|
||||
{
|
||||
// allow fullscreen mode by default
|
||||
// and use new `allow` attribute
|
||||
if (
|
||||
isset($attr['allow']) === false &&
|
||||
($attr['allowfullscreen'] ?? true) === true
|
||||
) {
|
||||
$attr['allow'] = 'fullscreen';
|
||||
$attr['allowfullscreen'] = true;
|
||||
}
|
||||
|
||||
return $attr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Embeds a Vimeo video by URL in an `<iframe>`
|
||||
*
|
||||
* @param string $url Vimeo video URL
|
||||
* @param array $options Query params for the embed URL
|
||||
* @param array $attr Additional attributes for the `<iframe>` tag
|
||||
* @return string|null The generated HTML
|
||||
*/
|
||||
public static function vimeo(
|
||||
string $url,
|
||||
array $options = [],
|
||||
array $attr = []
|
||||
): string|null {
|
||||
$uri = new Uri($url);
|
||||
$path = $uri->path();
|
||||
$query = $uri->query();
|
||||
|
||||
$id = match ($uri->host()) {
|
||||
'vimeo.com', 'www.vimeo.com' => $path->last(),
|
||||
'player.vimeo.com' => $path->nth(1),
|
||||
default => null
|
||||
};
|
||||
|
||||
if (empty($id) === true || preg_match('!^[0-9]*$!', $id) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// append query params
|
||||
foreach ($options as $key => $value) {
|
||||
$query->$key = $value;
|
||||
}
|
||||
|
||||
// build the full video src URL
|
||||
$src = 'https://player.vimeo.com/video/' . $id . $query->toString(true);
|
||||
|
||||
return static::iframe($src, static::videoAttr($attr));
|
||||
}
|
||||
|
||||
/**
|
||||
* Embeds a YouTube video by URL in an `<iframe>`
|
||||
*
|
||||
* @param string $url YouTube video URL
|
||||
* @param array $options Query params for the embed URL
|
||||
* @param array $attr Additional attributes for the `<iframe>` tag
|
||||
* @return string|null The generated HTML
|
||||
*/
|
||||
public static function youtube(
|
||||
string $url,
|
||||
array $options = [],
|
||||
array $attr = []
|
||||
): string|null {
|
||||
if (preg_match('!youtu!i', $url) !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$uri = new Uri($url);
|
||||
$path = $uri->path();
|
||||
$query = $uri->query();
|
||||
$first = $path->first();
|
||||
$second = $path->nth(1);
|
||||
$host = 'https://' . $uri->host() . '/embed';
|
||||
$src = null;
|
||||
|
||||
$isYoutubeId = function (string|null $id = null): bool {
|
||||
if (empty($id) === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return preg_match('!^[a-zA-Z0-9_-]+$!', $id) === 1;
|
||||
};
|
||||
|
||||
switch ($path->toString()) {
|
||||
case 'embed/videoseries':
|
||||
case 'playlist':
|
||||
// playlists
|
||||
if ($isYoutubeId($query->list) === true) {
|
||||
$src = $host . '/videoseries';
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 'watch':
|
||||
// regular video URLs
|
||||
if ($isYoutubeId($query->v) === true) {
|
||||
$src = $host . '/' . $query->v;
|
||||
|
||||
$query->start = $query->t;
|
||||
unset($query->v, $query->t);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
// short URLs
|
||||
if (
|
||||
Str::contains($uri->host(), 'youtu.be') === true &&
|
||||
$isYoutubeId($first) === true
|
||||
) {
|
||||
$src = 'https://www.youtube.com/embed/' . $first;
|
||||
|
||||
$query->start = $query->t;
|
||||
unset($query->t);
|
||||
} elseif (
|
||||
in_array($first, ['embed', 'shorts']) === true &&
|
||||
$isYoutubeId($second) === true
|
||||
) {
|
||||
// embedded and shorts video URLs
|
||||
$src = $host . '/' . $second;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($src) === true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// append all query parameters
|
||||
foreach ($options as $key => $value) {
|
||||
$query->$key = $value;
|
||||
}
|
||||
|
||||
// build the full video src URL
|
||||
$src .= $query->toString(true);
|
||||
|
||||
// render the iframe
|
||||
return static::iframe($src, static::videoAttr($attr));
|
||||
}
|
||||
}
|
||||
333
kirby/src/Toolkit/I18n.php
Normal file
333
kirby/src/Toolkit/I18n.php
Normal file
@@ -0,0 +1,333 @@
|
||||
<?php
|
||||
|
||||
namespace Kirby\Toolkit;
|
||||
|
||||
use Closure;
|
||||
use NumberFormatter;
|
||||
|
||||
/**
|
||||
* Localization class, roughly inspired by VueI18n
|
||||
*
|
||||
* @package Kirby Toolkit
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class I18n
|
||||
{
|
||||
/**
|
||||
* Custom loader function
|
||||
*/
|
||||
public static Closure|null $load = null;
|
||||
|
||||
/**
|
||||
* Current locale
|
||||
*/
|
||||
public static string|Closure|null $locale = 'en';
|
||||
|
||||
/**
|
||||
* All registered translations
|
||||
*/
|
||||
public static array $translations = [];
|
||||
|
||||
/**
|
||||
* The fallback locale or a list of fallback locales
|
||||
*/
|
||||
public static string|array|Closure|null $fallback = ['en'];
|
||||
|
||||
/**
|
||||
* Cache of `NumberFormatter` objects by locale
|
||||
*/
|
||||
protected static array $decimalsFormatters = [];
|
||||
|
||||
/**
|
||||
* Returns the list of fallback locales
|
||||
*/
|
||||
public static function fallbacks(): array
|
||||
{
|
||||
if (is_callable(static::$fallback) === true) {
|
||||
static::$fallback = (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
|
||||
*
|
||||
* @param bool $none If true, 'none' will be returned if the count is 0
|
||||
*/
|
||||
public static function form(int $count, bool $none = false): string
|
||||
{
|
||||
if ($none === true && $count === 0) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
return $count === 1 ? 'singular' : 'plural';
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a number
|
||||
*/
|
||||
public static function formatNumber(int|float $number, string $locale = null): string
|
||||
{
|
||||
$locale ??= static::locale();
|
||||
$formatter = static::decimalNumberFormatter($locale);
|
||||
$number = $formatter?->format($number) ?? $number;
|
||||
return (string)$number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current locale code
|
||||
*/
|
||||
public static function locale(): string
|
||||
{
|
||||
if (is_callable(static::$locale) === true) {
|
||||
static::$locale = (static::$locale)();
|
||||
}
|
||||
|
||||
if (is_string(static::$locale) === true) {
|
||||
return static::$locale;
|
||||
}
|
||||
|
||||
return static::$locale = 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate by key and then replace
|
||||
* placeholders in the text
|
||||
*/
|
||||
public static function template(
|
||||
string $key,
|
||||
string|array $fallback = null,
|
||||
array|null $replace = null,
|
||||
string|null $locale = null
|
||||
): string {
|
||||
if (is_array($fallback) === true) {
|
||||
$replace = $fallback;
|
||||
$fallback = null;
|
||||
$locale = null;
|
||||
}
|
||||
|
||||
$template = static::translate($key, $fallback, $locale);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current or any other translation
|
||||
* by locale. If the translation does not exist
|
||||
* yet, the loader will try to load it, if defined.
|
||||
*/
|
||||
public static function translation(string $locale = null): array
|
||||
{
|
||||
$locale ??= static::locale();
|
||||
|
||||
if ($translation = static::$translations[$locale] ?? null) {
|
||||
return $translation;
|
||||
}
|
||||
|
||||
if (static::$load instanceof Closure) {
|
||||
return static::$translations[$locale] = (static::$load)($locale);
|
||||
}
|
||||
|
||||
// try to use language code, e.g. `es` when locale is `es_ES`
|
||||
if ($translation = static::$translations[Str::before($locale, '_')] ?? null) {
|
||||
return $translation;
|
||||
}
|
||||
|
||||
return static::$translations[$locale] = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all loaded or defined translations
|
||||
*/
|
||||
public static function translations(): array
|
||||
{
|
||||
return static::$translations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns (and creates) a decimal number formatter for a given locale
|
||||
*/
|
||||
protected static function decimalNumberFormatter(string $locale): NumberFormatter|null
|
||||
{
|
||||
if ($formatter = static::$decimalsFormatters[$locale] ?? null) {
|
||||
return $formatter;
|
||||
}
|
||||
|
||||
if (
|
||||
extension_loaded('intl') !== true ||
|
||||
class_exists('NumberFormatter') !== true
|
||||
) {
|
||||
return null; // @codeCoverageIgnore
|
||||
}
|
||||
|
||||
return static::$decimalsFormatters[$locale] = new NumberFormatter($locale, NumberFormatter::DECIMAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates amounts
|
||||
*
|
||||
* Translation definition options:
|
||||
* - Translation is a simple string: `{{ count }}` gets replaced in the template
|
||||
* - Translation is an array with a value for each count: Chooses the correct template and
|
||||
* replaces `{{ count }}` in the template; if no specific template for the input count is
|
||||
* defined, the template that is defined last in the translation array is used
|
||||
* - Translation is a callback with a `$count` argument: Returns the callback return value
|
||||
*
|
||||
* @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
|
||||
) {
|
||||
$locale ??= static::locale();
|
||||
$translation = static::translate($key, null, $locale);
|
||||
|
||||
if ($translation === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($translation instanceof Closure) {
|
||||
return $translation($count);
|
||||
}
|
||||
|
||||
$message = match (true) {
|
||||
is_string($translation) => $translation,
|
||||
isset($translation[$count]) => $translation[$count],
|
||||
default => end($translation)
|
||||
};
|
||||
|
||||
if ($formatNumber === true) {
|
||||
$count = static::formatNumber($count, $locale);
|
||||
}
|
||||
|
||||
return Str::template($message, compact('count'));
|
||||
}
|
||||
}
|
||||
153
kirby/src/Toolkit/Iterator.php
Normal file
153
kirby/src/Toolkit/Iterator.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace Kirby\Toolkit;
|
||||
|
||||
use ArrayIterator;
|
||||
use IteratorAggregate;
|
||||
|
||||
/**
|
||||
* Extended version of PHP's iterator
|
||||
* class that builds the foundation of our
|
||||
* Collection class.
|
||||
*
|
||||
* @package Kirby Toolkit
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*
|
||||
* @psalm-suppress MissingTemplateParam Implementing template params
|
||||
* in this class would require
|
||||
* implementing them throughout
|
||||
* the code base: https://github.com/getkirby/kirby/pull/4886#pullrequestreview-1203577545
|
||||
*/
|
||||
class Iterator implements IteratorAggregate
|
||||
{
|
||||
public array $data = [];
|
||||
|
||||
public function __construct(array $data = [])
|
||||
{
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an iterator for the items.
|
||||
*/
|
||||
public function getIterator(): ArrayIterator
|
||||
{
|
||||
return new ArrayIterator($this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current key
|
||||
*/
|
||||
public function key(): int|string|null
|
||||
{
|
||||
return key($this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of all keys
|
||||
*/
|
||||
public function keys(): array
|
||||
{
|
||||
return array_keys($this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current element
|
||||
*/
|
||||
public function current(): mixed
|
||||
{
|
||||
return current($this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the cursor to the previous element
|
||||
* and returns it
|
||||
*/
|
||||
public function prev(): mixed
|
||||
{
|
||||
return prev($this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the cursor to the next element
|
||||
* and returns it
|
||||
*/
|
||||
public function next(): mixed
|
||||
{
|
||||
return next($this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the cursor to the first element
|
||||
*/
|
||||
public function rewind(): void
|
||||
{
|
||||
reset($this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current element is valid
|
||||
*/
|
||||
public function valid(): bool
|
||||
{
|
||||
return $this->current() !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts all elements
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return count($this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to find the index number for the given element
|
||||
*
|
||||
* @param mixed $needle the element to search for
|
||||
* @return int|false the index (int) of the element or false
|
||||
*/
|
||||
public function indexOf(mixed $needle): int|false
|
||||
{
|
||||
return array_search($needle, array_values($this->data));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to find the key for the given element
|
||||
*
|
||||
* @param mixed $needle the element to search for
|
||||
* @return int|string|false the name of the key or false
|
||||
*/
|
||||
public function keyOf(mixed $needle): int|string|false
|
||||
{
|
||||
return array_search($needle, $this->data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks by key if an element is included
|
||||
*/
|
||||
public function has(mixed $key): bool
|
||||
{
|
||||
return isset($this->data[$key]) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current key is set
|
||||
*/
|
||||
public function __isset(mixed $key): bool
|
||||
{
|
||||
return $this->has($key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified var_dump output
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function __debugInfo(): array
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
}
|
||||
48
kirby/src/Toolkit/LazyValue.php
Normal file
48
kirby/src/Toolkit/LazyValue.php
Normal 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;
|
||||
}
|
||||
}
|
||||
185
kirby/src/Toolkit/Locale.php
Normal file
185
kirby/src/Toolkit/Locale.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace Kirby\Toolkit;
|
||||
|
||||
use Kirby\Exception\Exception;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* PHP locale handling
|
||||
* @since 3.5.0
|
||||
*
|
||||
* @package Kirby Toolkit
|
||||
* @author Lukas Bestle <lukas@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
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'
|
||||
];
|
||||
|
||||
/**
|
||||
* Converts a normalized locale array to an array with the
|
||||
* locale constants replaced with their string representations
|
||||
*/
|
||||
public static function export(array $locale): array
|
||||
{
|
||||
$return = [];
|
||||
$constants = static::supportedConstants(true);
|
||||
|
||||
// replace the keys in the locale data array with the locale names
|
||||
foreach ($locale as $key => $value) {
|
||||
// use string representation for key
|
||||
// if it is a valid constant
|
||||
$return[$constants[$key] ?? $key] = $value;
|
||||
}
|
||||
|
||||
return $return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current locale value for
|
||||
* a specified or for all locale categories
|
||||
* @since 3.5.6
|
||||
*
|
||||
* @param int|string $category Locale category constant or constant name
|
||||
* @return array|string Associative array if `LC_ALL` was passed (default), otherwise string
|
||||
*
|
||||
* @throws \Kirby\Exception\Exception If the locale cannot be determined
|
||||
* @throws \Kirby\Exception\InvalidArgumentException If the provided locale category is invalid
|
||||
*/
|
||||
public static function get(int|string $category = LC_ALL): array|string
|
||||
{
|
||||
$normalizedCategory = static::normalizeConstant($category);
|
||||
|
||||
if (is_int($normalizedCategory) !== true) {
|
||||
throw new InvalidArgumentException('Invalid locale category "' . $category . '"');
|
||||
}
|
||||
|
||||
if ($normalizedCategory !== LC_ALL) {
|
||||
// `setlocale(..., 0)` actually *gets* the locale
|
||||
$locale = setlocale($normalizedCategory, 0);
|
||||
|
||||
if (is_string($locale) !== true) {
|
||||
throw new Exception('Could not determine locale for category "' . $category . '"');
|
||||
}
|
||||
|
||||
return $locale;
|
||||
}
|
||||
|
||||
// no specific `$category` was passed, make a list of all locales
|
||||
$array = [];
|
||||
foreach (static::supportedConstants() as $constant => $name) {
|
||||
// `setlocale(..., 0)` actually *gets* the locale
|
||||
$array[$constant] = setlocale($constant, '0');
|
||||
}
|
||||
|
||||
// if all values are the same, we can use `LC_ALL`
|
||||
// instead of a long array with all constants
|
||||
if (count(array_unique($array)) === 1) {
|
||||
return [
|
||||
LC_ALL => array_shift($array)
|
||||
];
|
||||
}
|
||||
|
||||
return $array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a locale string or an array with constant or
|
||||
* string keys to a normalized constant => value array
|
||||
*
|
||||
* @param array|string $locale
|
||||
*/
|
||||
public static function normalize($locale): array
|
||||
{
|
||||
if (is_array($locale) === true) {
|
||||
// replace string constant keys with the constant values
|
||||
$convertedLocale = [];
|
||||
foreach ($locale as $key => $value) {
|
||||
$convertedLocale[static::normalizeConstant($key)] = $value;
|
||||
}
|
||||
|
||||
return $convertedLocale;
|
||||
}
|
||||
|
||||
if (is_string($locale) === true) {
|
||||
return [LC_ALL => $locale];
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Locale must be string or array');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the PHP locale with a locale string or
|
||||
* an array with constant or string keys
|
||||
*/
|
||||
public static function set(array|string $locale): void
|
||||
{
|
||||
$locale = static::normalize($locale);
|
||||
|
||||
// locale for core string functions
|
||||
foreach ($locale as $key => $value) {
|
||||
setlocale($key, $value);
|
||||
}
|
||||
|
||||
// locale for the intl extension
|
||||
if (
|
||||
function_exists('locale_set_default') === true &&
|
||||
$timeLocale = $locale[LC_TIME] ?? $locale[LC_ALL] ?? null
|
||||
) {
|
||||
locale_set_default($timeLocale);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to convert an `LC_*` constant name
|
||||
* to its constant value
|
||||
*/
|
||||
protected static function normalizeConstant(int|string $constant): int|string
|
||||
{
|
||||
if (
|
||||
is_string($constant) === true &&
|
||||
Str::startsWith($constant, 'LC_') === true
|
||||
) {
|
||||
return constant($constant);
|
||||
}
|
||||
|
||||
// already an int or we cannot convert it safely
|
||||
return $constant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an associative array with the locales
|
||||
* that are actually supported on this system
|
||||
*
|
||||
* @param bool $withAll If set to `true`, `LC_ALL` is returned as well
|
||||
*/
|
||||
protected static function supportedConstants(bool $withAll = false): array
|
||||
{
|
||||
$names = static::LOCALE_CONSTANTS;
|
||||
if ($withAll === true) {
|
||||
array_unshift($names, 'LC_ALL');
|
||||
}
|
||||
|
||||
$constants = [];
|
||||
foreach ($names as $name) {
|
||||
if (defined($name) === true) {
|
||||
$constants[constant($name)] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
return $constants;
|
||||
}
|
||||
}
|
||||
105
kirby/src/Toolkit/Obj.php
Normal file
105
kirby/src/Toolkit/Obj.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace Kirby\Toolkit;
|
||||
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use stdClass;
|
||||
|
||||
/**
|
||||
* Super simple stdClass extension with
|
||||
* magic getter methods for all properties
|
||||
*
|
||||
* @package Kirby Toolkit
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class Obj extends stdClass
|
||||
{
|
||||
public function __construct(array $data = [])
|
||||
{
|
||||
foreach ($data as $key => $val) {
|
||||
$this->$key = $val;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic getter
|
||||
*/
|
||||
public function __call(string $property, array $arguments)
|
||||
{
|
||||
return $this->$property ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Improved `var_dump` output
|
||||
* @codeCoverageIgnore
|
||||
*/
|
||||
public function __debugInfo(): array
|
||||
{
|
||||
return $this->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic property getter
|
||||
*/
|
||||
public function __get(string $property)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets one or multiple properties of the object
|
||||
*
|
||||
* @param mixed $fallback If multiple properties are requested:
|
||||
* Associative array of fallback values per key
|
||||
*/
|
||||
public function get(string|array $property, $fallback = null)
|
||||
{
|
||||
if (is_array($property)) {
|
||||
$fallback ??= [];
|
||||
|
||||
if (is_array($fallback) === false) {
|
||||
throw new InvalidArgumentException('The fallback value must be an array when getting multiple properties');
|
||||
}
|
||||
|
||||
$result = [];
|
||||
foreach ($property as $key) {
|
||||
$result[$key] = $this->$key ?? $fallback[$key] ?? null;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
return $this->$property ?? $fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the object to an array
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach ((array)$this as $key => $value) {
|
||||
if (
|
||||
is_object($value) === true &&
|
||||
method_exists($value, 'toArray')
|
||||
) {
|
||||
$result[$key] = $value->toArray();
|
||||
} else {
|
||||
$result[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the object to a json string
|
||||
*/
|
||||
public function toJson(...$arguments): string
|
||||
{
|
||||
return json_encode($this->toArray(), ...$arguments);
|
||||
}
|
||||
}
|
||||
398
kirby/src/Toolkit/Pagination.php
Normal file
398
kirby/src/Toolkit/Pagination.php
Normal file
@@ -0,0 +1,398 @@
|
||||
<?php
|
||||
|
||||
namespace Kirby\Toolkit;
|
||||
|
||||
use Kirby\Exception\ErrorPageException;
|
||||
use Kirby\Exception\Exception;
|
||||
|
||||
/**
|
||||
* Basic pagination handling
|
||||
*
|
||||
* @package Kirby Toolkit
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class Pagination
|
||||
{
|
||||
/**
|
||||
* The current page
|
||||
*/
|
||||
protected int $page = 1;
|
||||
|
||||
/**
|
||||
* Total number of items
|
||||
*/
|
||||
protected int $total = 0;
|
||||
|
||||
/**
|
||||
* The number of items per page
|
||||
*/
|
||||
protected int $limit = 20;
|
||||
|
||||
/**
|
||||
* Whether validation of the pagination page
|
||||
* is enabled; will throw Exceptions if true
|
||||
*/
|
||||
public static bool $validate = true;
|
||||
|
||||
/**
|
||||
* Creates a new pagination object
|
||||
* with the given parameters
|
||||
*/
|
||||
public function __construct(array $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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a pagination instance for the given
|
||||
* collection with a flexible argument api
|
||||
*/
|
||||
public static function for(Collection $collection, ...$arguments): static
|
||||
{
|
||||
$a = $arguments[0] ?? null;
|
||||
$b = $arguments[1] ?? null;
|
||||
|
||||
$params = [];
|
||||
|
||||
// First argument is a pagination object
|
||||
if ($a instanceof static) {
|
||||
return $a;
|
||||
}
|
||||
|
||||
if (is_array($a) === true) {
|
||||
// First argument is an option array
|
||||
// $collection->paginate([...])
|
||||
$params = $a;
|
||||
} elseif (is_int($a) === true && $b === null) {
|
||||
// First argument is the limit
|
||||
// $collection->paginate(10)
|
||||
$params['limit'] = $a;
|
||||
} elseif (is_int($a) === true && is_int($b) === true) {
|
||||
// First argument is the limit, second argument is the page
|
||||
// $collection->paginate(10, 2)
|
||||
$params['limit'] = $a;
|
||||
$params['page'] = $b;
|
||||
} elseif (is_int($a) === true && is_array($b) === true) {
|
||||
// First argument is the limit, second argument are options
|
||||
// $collection->paginate(10, [...])
|
||||
$params = $b;
|
||||
$params['limit'] = $a;
|
||||
}
|
||||
|
||||
// add the total count from the collection
|
||||
$params['total'] = $collection->count();
|
||||
|
||||
// remove null values to make later merges work properly
|
||||
$params = array_filter($params);
|
||||
|
||||
// create the pagination instance
|
||||
return new static($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Getter for the current page
|
||||
*/
|
||||
public function page(): int
|
||||
{
|
||||
return $this->page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Getter for the total number of items
|
||||
*/
|
||||
public function total(): int
|
||||
{
|
||||
return $this->total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Getter for the number of items per page
|
||||
*/
|
||||
public function limit(): int
|
||||
{
|
||||
return $this->limit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the first item on the page
|
||||
*/
|
||||
public function start(): int
|
||||
{
|
||||
$index = max(0, $this->page() - 1);
|
||||
return $index * $this->limit() + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the last item on the page
|
||||
*/
|
||||
public function end(): int
|
||||
{
|
||||
$value = min($this->total(), ($this->start() - 1) + $this->limit());
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the total number of pages
|
||||
*/
|
||||
public function pages(): int
|
||||
{
|
||||
if ($this->total() === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int)ceil($this->total() / $this->limit());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first page
|
||||
*/
|
||||
public function firstPage(): int
|
||||
{
|
||||
return $this->total() === 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last page
|
||||
*/
|
||||
public function lastPage(): int
|
||||
{
|
||||
return $this->pages();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the offset (i.e. for db queries)
|
||||
*/
|
||||
public function offset(): int
|
||||
{
|
||||
return $this->start() - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given page exists
|
||||
*/
|
||||
public function hasPage(int $page): bool
|
||||
{
|
||||
if ($page <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($page > $this->pages()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if there are any pages at all
|
||||
*/
|
||||
public function hasPages(): bool
|
||||
{
|
||||
return $this->total() > $this->limit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if there's a previous page
|
||||
*/
|
||||
public function hasPrevPage(): bool
|
||||
{
|
||||
return $this->page() > 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the previous page
|
||||
*/
|
||||
public function prevPage(): int|null
|
||||
{
|
||||
return $this->hasPrevPage() ? $this->page() - 1 : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if there's a next page
|
||||
*/
|
||||
public function hasNextPage(): bool
|
||||
{
|
||||
return $this->end() < $this->total();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next page
|
||||
*/
|
||||
public function nextPage(): int|null
|
||||
{
|
||||
return $this->hasNextPage() ? $this->page() + 1 : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current page is the first page
|
||||
*/
|
||||
public function isFirstPage(): bool
|
||||
{
|
||||
return $this->page() === $this->firstPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current page is the last page
|
||||
*/
|
||||
public function isLastPage(): bool
|
||||
{
|
||||
return $this->page() === $this->lastPage();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a range of page numbers for Google-like pagination
|
||||
*/
|
||||
public function range(int $range = 5): array
|
||||
{
|
||||
$page = $this->page();
|
||||
$pages = $this->pages();
|
||||
$start = 1;
|
||||
$end = $pages;
|
||||
|
||||
if ($pages <= $range) {
|
||||
return range($start, $end);
|
||||
}
|
||||
|
||||
$middle = (int)floor($range / 2);
|
||||
$start = $page - $middle + ($range % 2 === 0);
|
||||
$end = $start + $range - 1;
|
||||
|
||||
if ($start <= 0) {
|
||||
$end = $range;
|
||||
$start = 1;
|
||||
}
|
||||
|
||||
if ($end > $pages) {
|
||||
$start = $pages - $range + 1;
|
||||
$end = $pages;
|
||||
}
|
||||
|
||||
return range($start, $end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first page of the created range
|
||||
*/
|
||||
public function rangeStart(int $range = 5): int
|
||||
{
|
||||
return $this->range($range)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last page of the created range
|
||||
*/
|
||||
public function rangeEnd(int $range = 5): int
|
||||
{
|
||||
$range = $this->range($range);
|
||||
return array_pop($range);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the number of items per page
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
protected function setLimit(int $limit = 20): static
|
||||
{
|
||||
if ($limit < 1) {
|
||||
throw new Exception('Invalid pagination limit: ' . $limit);
|
||||
}
|
||||
|
||||
$this->limit = $limit;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the total number of items
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
protected function setTotal(int $total = 0): static
|
||||
{
|
||||
if ($total < 0) {
|
||||
throw new Exception('Invalid total number of items: ' . $total);
|
||||
}
|
||||
|
||||
$this->total = $total;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current page
|
||||
*
|
||||
* @param int|string|null $page Int or int in string form;
|
||||
* automatically determined if null
|
||||
* @return $this
|
||||
*/
|
||||
protected function setPage(int|string|null $page = null): static
|
||||
{
|
||||
// if $page is null, it is set to a default in the setProperties() method
|
||||
if ($page !== null) {
|
||||
if (is_numeric($page) !== true || $page < 0) {
|
||||
throw new Exception('Invalid page number: ' . $page);
|
||||
}
|
||||
|
||||
$this->page = (int)$page;
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array with all properties
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'page' => $this->page(),
|
||||
'firstPage' => $this->firstPage(),
|
||||
'lastPage' => $this->lastPage(),
|
||||
'pages' => $this->pages(),
|
||||
'offset' => $this->offset(),
|
||||
'limit' => $this->limit(),
|
||||
'total' => $this->total(),
|
||||
'start' => $this->start(),
|
||||
'end' => $this->end(),
|
||||
];
|
||||
}
|
||||
}
|
||||
152
kirby/src/Toolkit/Properties.php
Normal file
152
kirby/src/Toolkit/Properties.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace Kirby\Toolkit;
|
||||
|
||||
use Exception;
|
||||
use ReflectionMethod;
|
||||
|
||||
/**
|
||||
* Properties
|
||||
* @deprecated 4.0.0 Will be remove in Kirby 5
|
||||
* @codeCoverageIgnore
|
||||
*
|
||||
* @package Kirby Toolkit
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
trait Properties
|
||||
{
|
||||
protected $propertyData = [];
|
||||
|
||||
/**
|
||||
* Creates an instance with the same
|
||||
* initial properties.
|
||||
*
|
||||
* @param array $props
|
||||
* @return static
|
||||
*/
|
||||
public function clone(array $props = [])
|
||||
{
|
||||
return new static(array_replace_recursive($this->propertyData, $props));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a clone and fetches all
|
||||
* lazy-loaded getters to get a full copy
|
||||
*
|
||||
* @return static
|
||||
*/
|
||||
public function hardcopy()
|
||||
{
|
||||
$clone = $this->clone();
|
||||
$clone->propertiesToArray();
|
||||
return $clone;
|
||||
}
|
||||
|
||||
protected function isRequiredProperty(string $name): bool
|
||||
{
|
||||
$method = new ReflectionMethod($this, 'set' . $name);
|
||||
return $method->getNumberOfRequiredParameters() > 0;
|
||||
}
|
||||
|
||||
protected function propertiesToArray()
|
||||
{
|
||||
$array = [];
|
||||
|
||||
foreach (get_object_vars($this) as $name => $default) {
|
||||
if ($name === 'propertyData') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (method_exists($this, 'convert' . $name . 'ToArray') === true) {
|
||||
$array[$name] = $this->{'convert' . $name . 'ToArray'}();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (method_exists($this, $name) === true) {
|
||||
$method = new ReflectionMethod($this, $name);
|
||||
|
||||
if ($method->isPublic() === true) {
|
||||
$value = $this->$name();
|
||||
|
||||
if (is_object($value) === false) {
|
||||
$array[$name] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ksort($array);
|
||||
|
||||
return $array;
|
||||
}
|
||||
|
||||
protected function setOptionalProperties(array $props, array $optional)
|
||||
{
|
||||
$this->propertyData = array_merge($this->propertyData, $props);
|
||||
|
||||
foreach ($optional as $propertyName) {
|
||||
if (isset($props[$propertyName]) === true) {
|
||||
$this->{'set' . $propertyName}($props[$propertyName]);
|
||||
} else {
|
||||
$this->{'set' . $propertyName}();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function setProperties($props, array $keys = null)
|
||||
{
|
||||
foreach (get_object_vars($this) as $name => $default) {
|
||||
if ($name === 'propertyData') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->setProperty($name, $props[$name] ?? $default);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function setProperty($name, $value, $required = null)
|
||||
{
|
||||
// use a setter if it exists
|
||||
if (method_exists($this, 'set' . $name) === false) {
|
||||
return $this;
|
||||
}
|
||||
|
||||
// fetch the default value from the property
|
||||
$value ??= $this->$name ?? null;
|
||||
|
||||
// store all original properties, to be able to clone them later
|
||||
$this->propertyData[$name] = $value;
|
||||
|
||||
// handle empty values
|
||||
if ($value === null) {
|
||||
// replace null with a default value, if a default handler exists
|
||||
if (method_exists($this, 'default' . $name) === true) {
|
||||
$value = $this->{'default' . $name}();
|
||||
}
|
||||
|
||||
// check for required properties
|
||||
if ($value === null && ($required ?? $this->isRequiredProperty($name)) === true) {
|
||||
throw new Exception(sprintf('The property "%s" is required', $name));
|
||||
}
|
||||
}
|
||||
|
||||
// call the setter with the final value
|
||||
return $this->{'set' . $name}($value);
|
||||
}
|
||||
|
||||
protected function setRequiredProperties(array $props, array $required)
|
||||
{
|
||||
foreach ($required as $propertyName) {
|
||||
if (isset($props[$propertyName]) !== true) {
|
||||
throw new Exception(sprintf('The property "%s" is required', $propertyName));
|
||||
}
|
||||
|
||||
$this->{'set' . $propertyName}($props[$propertyName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
58
kirby/src/Toolkit/Silo.php
Normal file
58
kirby/src/Toolkit/Silo.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace Kirby\Toolkit;
|
||||
|
||||
/**
|
||||
* The Silo class is a core class to handle
|
||||
* setting, getting and removing static data of
|
||||
* a singleton.
|
||||
*
|
||||
* @package Kirby Toolkit
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class Silo
|
||||
{
|
||||
public static array $data = [];
|
||||
|
||||
/**
|
||||
* Setter for new data
|
||||
*/
|
||||
public static function set(string|array $key, $value = null): array
|
||||
{
|
||||
if (is_array($key) === true) {
|
||||
return static::$data = array_merge(static::$data, $key);
|
||||
}
|
||||
|
||||
static::$data[$key] = $value;
|
||||
return static::$data;
|
||||
}
|
||||
|
||||
public static function get(string|array $key = null, $default = null)
|
||||
{
|
||||
if ($key === null) {
|
||||
return static::$data;
|
||||
}
|
||||
|
||||
return A::get(static::$data, $key, $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes an item from the data array
|
||||
*/
|
||||
public static function remove(string $key = null): array
|
||||
{
|
||||
// reset the entire array
|
||||
if ($key === null) {
|
||||
return static::$data = [];
|
||||
}
|
||||
|
||||
// unset a single key
|
||||
unset(static::$data[$key]);
|
||||
|
||||
// return the array without the removed key
|
||||
return static::$data;
|
||||
}
|
||||
}
|
||||
1507
kirby/src/Toolkit/Str.php
Normal file
1507
kirby/src/Toolkit/Str.php
Normal file
File diff suppressed because it is too large
Load Diff
216
kirby/src/Toolkit/SymmetricCrypto.php
Normal file
216
kirby/src/Toolkit/SymmetricCrypto.php
Normal file
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
namespace Kirby\Toolkit;
|
||||
|
||||
use Kirby\Data\Json;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Exception\LogicException;
|
||||
use SensitiveParameter;
|
||||
|
||||
/**
|
||||
* User-friendly and safe abstraction for symmetric
|
||||
* authenticated encryption and decryption using the
|
||||
* PHP `sodium` extension
|
||||
* @since 3.9.8
|
||||
*
|
||||
* @package Kirby Toolkit
|
||||
* @author Lukas Bestle <lukas@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class SymmetricCrypto
|
||||
{
|
||||
/**
|
||||
* Cache for secret keys derived from the password
|
||||
* indexed by the used salt and limits
|
||||
*/
|
||||
protected array $secretKeysByOptions = [];
|
||||
|
||||
/**
|
||||
* Initializes the keys used for crypto, both optional
|
||||
*
|
||||
* @param string|null $password Password to be derived into a `$secretKey`
|
||||
* @param string|null $secretKey 256-bit key, alternatively a `$password` can be used
|
||||
*/
|
||||
public function __construct(
|
||||
#[SensitiveParameter]
|
||||
protected string|null $password = null,
|
||||
#[SensitiveParameter]
|
||||
protected string|null $secretKey = null,
|
||||
) {
|
||||
if ($password !== null && $secretKey !== null) {
|
||||
throw new InvalidArgumentException('Passing both a secret key and a password is not supported');
|
||||
}
|
||||
|
||||
if ($secretKey !== null && strlen($secretKey) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
|
||||
throw new InvalidArgumentException('Invalid secret key length, expected ' . SODIUM_CRYPTO_SECRETBOX_KEYBYTES . ' bytes');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide values of secrets when printing the object
|
||||
*/
|
||||
public function __debugInfo(): array
|
||||
{
|
||||
return [
|
||||
'hasPassword' => isset($this->password),
|
||||
'hasSecretKey' => isset($this->secretKey),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipes the secrets from memory when they are no longer needed
|
||||
*/
|
||||
public function __destruct()
|
||||
{
|
||||
$this->memzero($this->password);
|
||||
$this->memzero($this->secretKey);
|
||||
|
||||
foreach ($this->secretKeysByOptions as $key => &$value) {
|
||||
$this->memzero($value);
|
||||
unset($this->secretKeysByOptions[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts JSON data encrypted by `SymmetricCrypto::encrypt()` using the secret key or password
|
||||
*
|
||||
* <code>
|
||||
* // decryption with a password
|
||||
* $crypto = new SymmetricCrypto(password: 'super secure');
|
||||
* $plaintext = $crypto->decrypt('a very confidential string');
|
||||
*
|
||||
* // decryption with a previously generated key
|
||||
* $crypto = new SymmetricCrypto(secretKey: $secretKey);
|
||||
* $plaintext = $crypto->decrypt('{"mode":"secretbox"...}');
|
||||
* </code>
|
||||
*/
|
||||
public function decrypt(string $json): string
|
||||
{
|
||||
$props = Json::decode($json);
|
||||
|
||||
if (($props['mode'] ?? null) !== 'secretbox') {
|
||||
throw new InvalidArgumentException('Unsupported encryption mode "' . ($props['mode'] ?? '') . '"');
|
||||
}
|
||||
|
||||
if (
|
||||
isset($props['data']) !== true ||
|
||||
isset($props['nonce']) !== true ||
|
||||
isset($props['salt']) !== true ||
|
||||
isset($props['limits']) !== true
|
||||
) {
|
||||
throw new InvalidArgumentException('Input data does not contain all required props');
|
||||
}
|
||||
|
||||
$data = base64_decode($props['data']);
|
||||
$nonce = base64_decode($props['nonce']);
|
||||
$salt = base64_decode($props['salt']);
|
||||
$limits = $props['limits'];
|
||||
|
||||
$plaintext = sodium_crypto_secretbox_open($data, $nonce, $this->secretKey($salt, $limits));
|
||||
|
||||
if (is_string($plaintext) !== true) {
|
||||
throw new LogicException('Encrypted string was tampered with');
|
||||
}
|
||||
|
||||
return $plaintext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a string using the secret key or password
|
||||
*
|
||||
* <code>
|
||||
* // encryption with a password
|
||||
* $crypto = new SymmetricCrypto(password: 'super secure');
|
||||
* $ciphertext = $crypto->encrypt('a very confidential string');
|
||||
*
|
||||
* // encryption with a random key
|
||||
* $crypto = new SymmetricCrypto();
|
||||
* $ciphertext = $crypto->encrypt('a very confidential string');
|
||||
* $secretKey = $crypto->secretKey();
|
||||
* </code>
|
||||
*/
|
||||
public function encrypt(
|
||||
#[SensitiveParameter]
|
||||
string $string
|
||||
): string {
|
||||
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
|
||||
$salt = random_bytes(SODIUM_CRYPTO_PWHASH_SALTBYTES);
|
||||
$limits = [SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE, SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE];
|
||||
$key = $this->secretKey($salt, $limits);
|
||||
|
||||
$ciphertext = sodium_crypto_secretbox($string, $nonce, $key);
|
||||
|
||||
// bundle all necessary information in a JSON object;
|
||||
// always include the salt and limits to hide whether a key or password was used
|
||||
return Json::encode([
|
||||
'mode' => 'secretbox',
|
||||
'data' => base64_encode($ciphertext),
|
||||
'nonce' => base64_encode($nonce),
|
||||
'salt' => base64_encode($salt),
|
||||
'limits' => $limits,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the required PHP `sodium` extension is available
|
||||
*/
|
||||
public static function isAvailable(): bool
|
||||
{
|
||||
return defined('SODIUM_LIBRARY_MAJOR_VERSION') === true && SODIUM_LIBRARY_MAJOR_VERSION >= 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the binary secret key, optionally derived from the password
|
||||
* or randomly generated
|
||||
*
|
||||
* @param string|null $salt Salt for password-based key derivation
|
||||
* @param array|null $limits Processing limits for password-based key derivation
|
||||
*/
|
||||
public function secretKey(
|
||||
#[SensitiveParameter]
|
||||
string|null $salt = null,
|
||||
array|null $limits = null
|
||||
): string {
|
||||
if (isset($this->secretKey) === true) {
|
||||
return $this->secretKey;
|
||||
}
|
||||
|
||||
// derive from password
|
||||
if (isset($this->password) === true) {
|
||||
if ($salt === null || $limits === null) {
|
||||
throw new InvalidArgumentException('Salt and limits are required when deriving a secret key from a password');
|
||||
}
|
||||
|
||||
// access from cache
|
||||
$options = $salt . ':' . implode(',', $limits);
|
||||
if (isset($this->secretKeysByOptions[$options]) === true) {
|
||||
return $this->secretKeysByOptions[$options];
|
||||
}
|
||||
|
||||
return $this->secretKeysByOptions[$options] = sodium_crypto_pwhash(
|
||||
SODIUM_CRYPTO_SECRETBOX_KEYBYTES,
|
||||
$this->password,
|
||||
$salt,
|
||||
$limits[0],
|
||||
$limits[1],
|
||||
SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13
|
||||
);
|
||||
}
|
||||
|
||||
// generate a random key
|
||||
return $this->secretKey = random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wipes a variable from memory if it is a string
|
||||
*/
|
||||
protected function memzero(mixed &$value): void
|
||||
{
|
||||
if (is_string($value) === true) {
|
||||
sodium_memzero($value);
|
||||
$value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
144
kirby/src/Toolkit/Totp.php
Normal file
144
kirby/src/Toolkit/Totp.php
Normal 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;
|
||||
}
|
||||
}
|
||||
50
kirby/src/Toolkit/Tpl.php
Normal file
50
kirby/src/Toolkit/Tpl.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Kirby\Toolkit;
|
||||
|
||||
use Kirby\Filesystem\F;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Simple PHP template engine
|
||||
*
|
||||
* @package Kirby Toolkit
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class Tpl
|
||||
{
|
||||
/**
|
||||
* Renders the template
|
||||
*
|
||||
* @throws Throwable
|
||||
*/
|
||||
public static function load(
|
||||
string|null $file = null,
|
||||
array $data = []
|
||||
): string {
|
||||
if ($file === null || is_file($file) === false) {
|
||||
return '';
|
||||
}
|
||||
|
||||
ob_start();
|
||||
|
||||
$exception = null;
|
||||
try {
|
||||
F::load($file, null, $data);
|
||||
} catch (Throwable $e) {
|
||||
$exception = $e;
|
||||
}
|
||||
|
||||
$content = ob_get_contents();
|
||||
ob_end_clean();
|
||||
|
||||
if ($exception !== null) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
634
kirby/src/Toolkit/V.php
Normal file
634
kirby/src/Toolkit/V.php
Normal file
@@ -0,0 +1,634 @@
|
||||
<?php
|
||||
|
||||
namespace Kirby\Toolkit;
|
||||
|
||||
use Countable;
|
||||
use Exception;
|
||||
use Kirby\Content\Field;
|
||||
use Kirby\Exception\InvalidArgumentException;
|
||||
use Kirby\Http\Idn;
|
||||
use Kirby\Uuid\Uuid;
|
||||
use ReflectionFunction;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* A set of validator methods
|
||||
*
|
||||
* @package Kirby Toolkit
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class V
|
||||
{
|
||||
/**
|
||||
* An array with all installed validators
|
||||
*/
|
||||
public static array $validators = [];
|
||||
|
||||
/**
|
||||
* Validates the given input with all passed rules
|
||||
* and returns an array with all error messages.
|
||||
* The array will be empty if the input is valid
|
||||
*/
|
||||
public static function errors(
|
||||
$input,
|
||||
array $rules,
|
||||
array $messages = []
|
||||
): array {
|
||||
$errors = static::value($input, $rules, $messages, false);
|
||||
|
||||
return $errors === true ? [] : $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a number of validators on a set of data and
|
||||
* checks if the data is invalid
|
||||
* @since 3.7.0
|
||||
*/
|
||||
public static function invalid(
|
||||
array $data = [],
|
||||
array $rules = [],
|
||||
array $messages = []
|
||||
): array {
|
||||
$errors = [];
|
||||
|
||||
foreach ($rules as $field => $validations) {
|
||||
$validationIndex = -1;
|
||||
|
||||
// See: http://php.net/manual/en/types.comparisons.php
|
||||
// only false for: null, undefined variable, '', []
|
||||
$value = $data[$field] ?? null;
|
||||
$filled = $value !== null && $value !== '' && $value !== [];
|
||||
$message = $messages[$field] ?? $field;
|
||||
|
||||
// True if there is an error message for each validation method.
|
||||
$messageArray = is_array($message);
|
||||
|
||||
foreach ($validations as $method => $options) {
|
||||
// If the index is numeric, there is no option
|
||||
// and `$value` is sent directly as a `$options` parameter
|
||||
if (is_numeric($method) === true) {
|
||||
$method = $options;
|
||||
$options = [$value];
|
||||
} else {
|
||||
if (is_array($options) === false) {
|
||||
$options = [$options];
|
||||
}
|
||||
|
||||
array_unshift($options, $value);
|
||||
}
|
||||
|
||||
$validationIndex++;
|
||||
|
||||
if ($method === 'required') {
|
||||
if ($filled) {
|
||||
// Field is required and filled.
|
||||
continue;
|
||||
}
|
||||
} elseif ($filled) {
|
||||
if (V::$method(...$options) === true) {
|
||||
// Field is filled and passes validation method.
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// If a field is not required and not filled, no validation should be done.
|
||||
continue;
|
||||
}
|
||||
|
||||
// If no continue was called we have a failed validation.
|
||||
if ($messageArray) {
|
||||
$errors[$field][] = $message[$validationIndex] ?? $field;
|
||||
} else {
|
||||
$errors[$field] = $message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a useful error message for the given validator
|
||||
* and the arguments. This is used mainly internally
|
||||
* to create error messages
|
||||
*/
|
||||
public static function message(
|
||||
string $validatorName,
|
||||
...$params
|
||||
): string|null {
|
||||
$validatorName = strtolower($validatorName);
|
||||
$translationKey = 'error.validation.' . $validatorName;
|
||||
$validators = array_change_key_case(static::$validators);
|
||||
$validator = $validators[$validatorName] ?? null;
|
||||
|
||||
if ($validator === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$reflection = new ReflectionFunction($validator);
|
||||
$arguments = [];
|
||||
|
||||
foreach ($reflection->getParameters() as $index => $parameter) {
|
||||
$value = $params[$index] ?? null;
|
||||
|
||||
if (is_array($value) === true) {
|
||||
foreach ($value as $key => $item) {
|
||||
if (is_array($item) === true) {
|
||||
$value[$key] = A::implode($item, '|');
|
||||
}
|
||||
}
|
||||
|
||||
$value = implode(', ', $value);
|
||||
}
|
||||
|
||||
$arguments[$parameter->getName()] = $value;
|
||||
}
|
||||
|
||||
return I18n::template($translationKey, 'The "' . $validatorName . '" validation failed', $arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of all validators
|
||||
*/
|
||||
public static function validators(): array
|
||||
{
|
||||
return static::$validators;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single value against
|
||||
* a set of rules, using all registered
|
||||
* validators
|
||||
*/
|
||||
public static function value(
|
||||
$value,
|
||||
array $rules,
|
||||
array $messages = [],
|
||||
bool $fail = true
|
||||
): bool|array {
|
||||
$errors = [];
|
||||
|
||||
foreach ($rules as $validatorName => $validatorOptions) {
|
||||
if (is_int($validatorName)) {
|
||||
$validatorName = $validatorOptions;
|
||||
$validatorOptions = [];
|
||||
}
|
||||
|
||||
if (is_array($validatorOptions) === false) {
|
||||
$validatorOptions = [$validatorOptions];
|
||||
}
|
||||
|
||||
$validatorName = strtolower($validatorName);
|
||||
|
||||
if (static::$validatorName($value, ...$validatorOptions) === false) {
|
||||
$message = $messages[$validatorName] ?? static::message($validatorName, $value, ...$validatorOptions);
|
||||
$errors[$validatorName] = $message;
|
||||
|
||||
if ($fail === true) {
|
||||
throw new Exception($message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return empty($errors) === true ? true : $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an input array against
|
||||
* a set of rules, using all registered
|
||||
* validators
|
||||
*/
|
||||
public static function input(array $input, array $rules): bool
|
||||
{
|
||||
foreach ($rules as $fieldName => $fieldRules) {
|
||||
$fieldValue = $input[$fieldName] ?? null;
|
||||
|
||||
// first check for required fields
|
||||
if (
|
||||
($fieldRules['required'] ?? false) === true &&
|
||||
$fieldValue === null
|
||||
) {
|
||||
throw new Exception(sprintf('The "%s" field is missing', $fieldName));
|
||||
}
|
||||
|
||||
// remove the required rule
|
||||
unset($fieldRules['required']);
|
||||
|
||||
// skip validation for empty fields
|
||||
if ($fieldValue === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
static::value($fieldValue, $fieldRules);
|
||||
} catch (Exception $e) {
|
||||
throw new Exception(sprintf($e->getMessage() . ' for field "%s"', $fieldName));
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls an installed validator and passes all arguments
|
||||
*/
|
||||
public static function __callStatic(string $method, array $arguments): bool
|
||||
{
|
||||
$method = strtolower($method);
|
||||
$validators = array_change_key_case(static::$validators);
|
||||
|
||||
// check for missing validators
|
||||
if (isset($validators[$method]) === false) {
|
||||
throw new Exception('The validator does not exist: ' . $method);
|
||||
}
|
||||
|
||||
return call_user_func_array($validators[$method], $arguments);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Default set of validators
|
||||
*/
|
||||
V::$validators = [
|
||||
/**
|
||||
* Valid: `'yes' | true | 1 | 'on'`
|
||||
*/
|
||||
'accepted' => function ($value): bool {
|
||||
return V::in($value, [1, true, 'yes', 'true', '1', 'on'], true) === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Valid: `a-z | A-Z`
|
||||
*/
|
||||
'alpha' => function ($value, bool $unicode = false): bool {
|
||||
return V::match($value, ($unicode === true ? '/^([\pL])+$/u' : '/^([a-z])+$/i')) === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Valid: `a-z | A-Z | 0-9`
|
||||
*/
|
||||
'alphanum' => function ($value, bool $unicode = false): bool {
|
||||
return V::match($value, ($unicode === true ? '/^[\pL\pN]+$/u' : '/^([a-z0-9])+$/i')) === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks for numbers within the given range
|
||||
*/
|
||||
'between' => function ($value, $min, $max): bool {
|
||||
return
|
||||
V::min($value, $min) === true &&
|
||||
V::max($value, $max) === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks with the callback sent by the user
|
||||
* It's ideal for one-time custom validations
|
||||
*/
|
||||
'callback' => function ($value, callable $callback): bool {
|
||||
return $callback($value);
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if the given string contains the given value
|
||||
*/
|
||||
'contains' => function ($value, $needle): bool {
|
||||
return Str::contains($value, $needle);
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks for a valid date or compares two
|
||||
* dates with each other.
|
||||
*
|
||||
* Pass only the first argument to check for a valid date.
|
||||
* Pass an operator as second argument and another date as
|
||||
* third argument to compare them.
|
||||
*/
|
||||
'date' => function (string|null $value, string $operator = null, string $test = null): bool {
|
||||
// make sure $value is a string
|
||||
$value ??= '';
|
||||
|
||||
$args = func_get_args();
|
||||
|
||||
// simple date validation
|
||||
if (count($args) === 1) {
|
||||
$date = date_parse($value);
|
||||
return $date !== false &&
|
||||
$date['error_count'] === 0 &&
|
||||
$date['warning_count'] === 0;
|
||||
}
|
||||
|
||||
$value = strtotime($value);
|
||||
$test = strtotime($test);
|
||||
|
||||
if (is_int($value) !== true || is_int($test) !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return match ($operator) {
|
||||
'!=' => $value !== $test,
|
||||
'<' => $value < $test,
|
||||
'>' => $value > $test,
|
||||
'<=' => $value <= $test,
|
||||
'>=' => $value >= $test,
|
||||
'==' => $value === $test,
|
||||
|
||||
default => throw new InvalidArgumentException('Invalid date comparison operator: "' . $operator . '". Allowed operators: "==", "!=", "<", "<=", ">", ">="')
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Valid: `'no' | false | 0 | 'off'`
|
||||
*/
|
||||
'denied' => function ($value): bool {
|
||||
return V::in($value, [0, false, 'no', 'false', '0', 'off'], true) === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks for a value, which does not equal the given value
|
||||
*/
|
||||
'different' => function ($value, $other, $strict = false): bool {
|
||||
if ($strict === true) {
|
||||
return $value !== $other;
|
||||
}
|
||||
return $value != $other;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks for valid email addresses
|
||||
*/
|
||||
'email' => function ($value): bool {
|
||||
if (filter_var($value, FILTER_VALIDATE_EMAIL) === false) {
|
||||
try {
|
||||
$email = Idn::encodeEmail($value);
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks for empty values
|
||||
*/
|
||||
'empty' => function ($value = null): bool {
|
||||
$empty = ['', null, []];
|
||||
|
||||
if (in_array($value, $empty, true) === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_countable($value) === true) {
|
||||
return count($value) === 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if the given string ends with the given value
|
||||
*/
|
||||
'endsWith' => function (string $value, string $end): bool {
|
||||
return Str::endsWith($value, $end);
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks for a valid filename
|
||||
*/
|
||||
'filename' => function ($value): bool {
|
||||
return
|
||||
V::match($value, '/^[a-z0-9@._-]+$/i') === true &&
|
||||
V::min($value, 2) === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if the value exists in a list of given values
|
||||
*/
|
||||
'in' => function ($value, array $in, bool $strict = false): bool {
|
||||
return in_array($value, $in, $strict) === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks for a valid integer
|
||||
*/
|
||||
'integer' => function ($value, bool $strict = false): bool {
|
||||
if ($strict === true) {
|
||||
return is_int($value) === true;
|
||||
}
|
||||
return filter_var($value, FILTER_VALIDATE_INT) !== false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks for a valid IP address
|
||||
*/
|
||||
'ip' => function ($value): bool {
|
||||
return filter_var($value, FILTER_VALIDATE_IP) !== false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks for valid json
|
||||
*/
|
||||
'json' => function ($value): bool {
|
||||
if (!is_string($value) || $value === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
json_decode($value);
|
||||
|
||||
return json_last_error() === JSON_ERROR_NONE;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if the value is lower than the second value
|
||||
*/
|
||||
'less' => function ($value, float $max): bool {
|
||||
return V::size($value, $max, '<') === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if the value matches the given regular expression
|
||||
*/
|
||||
'match' => function ($value, string $pattern): bool {
|
||||
return preg_match($pattern, (string)$value) === 1;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if the value does not exceed the maximum value
|
||||
*/
|
||||
'max' => function ($value, float $max): bool {
|
||||
return V::size($value, $max, '<=') === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if the value is higher than the minimum value
|
||||
*/
|
||||
'min' => function ($value, float $min): bool {
|
||||
return V::size($value, $min, '>=') === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if the number of characters in the value equals or is below the given maximum
|
||||
*/
|
||||
'maxLength' => function (string $value = null, $max): bool {
|
||||
return Str::length(trim($value)) <= $max;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if the number of characters in the value equals or is greater than the given minimum
|
||||
*/
|
||||
'minLength' => function (string $value = null, $min): bool {
|
||||
return Str::length(trim($value)) >= $min;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if the number of words in the value equals or is below the given maximum
|
||||
*/
|
||||
'maxWords' => function (string $value = null, $max): bool {
|
||||
return V::max(explode(' ', trim($value)), $max) === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if the number of words in the value equals or is below the given maximum
|
||||
*/
|
||||
'minWords' => function (string $value = null, $min): bool {
|
||||
return V::min(explode(' ', trim($value)), $min) === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if the first value is higher than the second value
|
||||
*/
|
||||
'more' => function ($value, float $min): bool {
|
||||
return V::size($value, $min, '>') === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks that the given string does not contain the second value
|
||||
*/
|
||||
'notContains' => function ($value, $needle): bool {
|
||||
return V::contains($value, $needle) === false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks that the given value is not empty
|
||||
*/
|
||||
'notEmpty' => function ($value): bool {
|
||||
return V::empty($value) === false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks that the given value is not in the given list of values
|
||||
*/
|
||||
'notIn' => function ($value, $notIn): bool {
|
||||
return V::in($value, $notIn) === false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks for a valid number / numeric value (float, int, double)
|
||||
*/
|
||||
'num' => function ($value): bool {
|
||||
return is_numeric($value) === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks if the value is present
|
||||
*/
|
||||
'required' => function ($value, $array = null): bool {
|
||||
// with reference array
|
||||
if (is_array($array) === true) {
|
||||
return isset($array[$value]) === true && V::notEmpty($array[$value]) === true;
|
||||
}
|
||||
|
||||
// without reference array
|
||||
return V::notEmpty($value);
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks that the first value equals the second value
|
||||
*/
|
||||
'same' => function ($value, $other, bool $strict = false): bool {
|
||||
if ($strict === true) {
|
||||
return $value === $other;
|
||||
}
|
||||
return $value == $other;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks that the value has the given size
|
||||
*/
|
||||
'size' => function ($value, $size, $operator = '=='): bool {
|
||||
// if value is field object, first convert it to a readable value
|
||||
// it is important to check at the beginning as the value can be string or numeric
|
||||
if ($value instanceof Field) {
|
||||
$value = $value->value();
|
||||
}
|
||||
|
||||
if (is_numeric($value) === true) {
|
||||
$count = $value;
|
||||
} elseif (is_string($value) === true) {
|
||||
$count = Str::length(trim($value));
|
||||
} elseif (is_array($value) === true) {
|
||||
$count = count($value);
|
||||
} elseif (is_object($value) === true) {
|
||||
if ($value instanceof Countable) {
|
||||
$count = count($value);
|
||||
} elseif (method_exists($value, 'count') === true) {
|
||||
$count = $value->count();
|
||||
} else {
|
||||
throw new Exception('$value is an uncountable object');
|
||||
}
|
||||
} else {
|
||||
throw new Exception('$value is of type without size');
|
||||
}
|
||||
|
||||
return match ($operator) {
|
||||
'<' => $count < $size,
|
||||
'>' => $count > $size,
|
||||
'<=' => $count <= $size,
|
||||
'>=' => $count >= $size,
|
||||
default => $count == $size
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks that the string starts with the given start value
|
||||
*/
|
||||
'startsWith' => function (string $value, string $start): bool {
|
||||
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
|
||||
*/
|
||||
'time' => function ($value): bool {
|
||||
return V::date($value);
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks for a valid Url
|
||||
*/
|
||||
'url' => function ($value): bool {
|
||||
// In search for the perfect regular expression: https://mathiasbynens.be/demo/url-regex
|
||||
// Added localhost support and removed 127.*.*.* ip restriction
|
||||
$regex = '_^(?:(?:https?|ftp):\/\/)(?:\S+(?::\S*)?@)?(?:(?!10(?:\.\d{1,3}){3})(?!169\.254(?:\.\d{1,3}){2})(?!192\.168(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:localhost)|(?:(?:[a-z\x{00a1}-\x{ffff}0-9]+-?)*[a-z\x{00a1}-\x{ffff}0-9]+)(?:\.(?:[a-z\x{00a1}-\x{ffff}0-9]+-?)*[a-z\x{00a1}-\x{ffff}0-9]+)*(?:\.(?:[a-z\x{00a1}-\x{ffff}]{2,})))(?::\d{2,5})?(?:\/[^\s]*)?$_iu';
|
||||
return preg_match($regex, $value ?? '') !== 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Checks for a valid Uuid, optionally for specific model type
|
||||
*/
|
||||
'uuid' => function (string $value, string $type = null): bool {
|
||||
return Uuid::is($value, $type);
|
||||
}
|
||||
];
|
||||
107
kirby/src/Toolkit/View.php
Normal file
107
kirby/src/Toolkit/View.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace Kirby\Toolkit;
|
||||
|
||||
use Exception;
|
||||
use Kirby\Filesystem\F;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Simple PHP view engine
|
||||
*
|
||||
* @package Kirby Toolkit
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class View
|
||||
{
|
||||
/**
|
||||
* Creates a new view object
|
||||
*/
|
||||
public function __construct(
|
||||
// The absolute path to the view file
|
||||
protected string $file,
|
||||
protected array $data = []
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the view's data array without globals
|
||||
*/
|
||||
public function data(): array
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the template file exists
|
||||
*/
|
||||
public function exists(): bool
|
||||
{
|
||||
return is_file($this->file()) === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the view file
|
||||
*/
|
||||
public function file(): string
|
||||
{
|
||||
return $this->file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an error message for the missing view exception
|
||||
*/
|
||||
protected function missingViewMessage(): string
|
||||
{
|
||||
return 'The view does not exist: ' . $this->file();
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the view
|
||||
*/
|
||||
public function render(): string
|
||||
{
|
||||
if ($this->exists() === false) {
|
||||
throw new Exception($this->missingViewMessage());
|
||||
}
|
||||
|
||||
ob_start();
|
||||
|
||||
try {
|
||||
F::load($this->file(), null, $this->data());
|
||||
} catch (Throwable $e) {
|
||||
$exception = $e;
|
||||
}
|
||||
|
||||
$content = ob_get_contents();
|
||||
ob_end_clean();
|
||||
|
||||
if (($exception ?? null) !== null) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see ::render()
|
||||
*/
|
||||
public function toString(): string
|
||||
{
|
||||
return $this->render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic string converter to enable
|
||||
* converting view objects to string
|
||||
*
|
||||
* @see ::render()
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->render();
|
||||
}
|
||||
}
|
||||
439
kirby/src/Toolkit/Xml.php
Normal file
439
kirby/src/Toolkit/Xml.php
Normal file
@@ -0,0 +1,439 @@
|
||||
<?php
|
||||
|
||||
namespace Kirby\Toolkit;
|
||||
|
||||
use Kirby\Cms\Helpers;
|
||||
use SimpleXMLElement;
|
||||
|
||||
/**
|
||||
* XML parser and creator class
|
||||
*
|
||||
* @package Kirby Toolkit
|
||||
* @author Bastian Allgeier <bastian@getkirby.com>
|
||||
* @link https://getkirby.com
|
||||
* @copyright Bastian Allgeier
|
||||
* @license https://opensource.org/licenses/MIT
|
||||
*/
|
||||
class Xml
|
||||
{
|
||||
/**
|
||||
* HTML to XML conversion table for entities
|
||||
*/
|
||||
public static array|null $entities = [
|
||||
' ' => ' ', '¡' => '¡', '¢' => '¢', '£' => '£', '¤' => '¤', '¥' => '¥', '¦' => '¦', '§' => '§',
|
||||
'¨' => '¨', '©' => '©', 'ª' => 'ª', '«' => '«', '¬' => '¬', '­' => '­', '®' => '®', '¯' => '¯',
|
||||
'°' => '°', '±' => '±', '²' => '²', '³' => '³', '´' => '´', 'µ' => 'µ', '¶' => '¶', '·' => '·',
|
||||
'¸' => '¸', '¹' => '¹', 'º' => 'º', '»' => '»', '¼' => '¼', '½' => '½', '¾' => '¾', '¿' => '¿',
|
||||
'À' => 'À', 'Á' => 'Á', 'Â' => 'Â', 'Ã' => 'Ã', 'Ä' => 'Ä', 'Å' => 'Å', 'Æ' => 'Æ', 'Ç' => 'Ç',
|
||||
'È' => 'È', 'É' => 'É', 'Ê' => 'Ê', 'Ë' => 'Ë', 'Ì' => 'Ì', 'Í' => 'Í', 'Î' => 'Î', 'Ï' => 'Ï',
|
||||
'Ð' => 'Ð', 'Ñ' => 'Ñ', 'Ò' => 'Ò', 'Ó' => 'Ó', 'Ô' => 'Ô', 'Õ' => 'Õ', 'Ö' => 'Ö', '×' => '×',
|
||||
'Ø' => 'Ø', 'Ù' => 'Ù', 'Ú' => 'Ú', 'Û' => 'Û', 'Ü' => 'Ü', 'Ý' => 'Ý', 'Þ' => 'Þ', 'ß' => 'ß',
|
||||
'à' => 'à', 'á' => 'á', 'â' => 'â', 'ã' => 'ã', 'ä' => 'ä', 'å' => 'å', 'æ' => 'æ', 'ç' => 'ç',
|
||||
'è' => 'è', 'é' => 'é', 'ê' => 'ê', 'ë' => 'ë', 'ì' => 'ì', 'í' => 'í', 'î' => 'î', 'ï' => 'ï',
|
||||
'ð' => 'ð', 'ñ' => 'ñ', 'ò' => 'ò', 'ó' => 'ó', 'ô' => 'ô', 'õ' => 'õ', 'ö' => 'ö', '÷' => '÷',
|
||||
'ø' => 'ø', 'ù' => 'ù', 'ú' => 'ú', 'û' => 'û', 'ü' => 'ü', 'ý' => 'ý', 'þ' => 'þ', 'ÿ' => 'ÿ',
|
||||
'ƒ' => 'ƒ', 'Α' => 'Α', 'Β' => 'Β', 'Γ' => 'Γ', 'Δ' => 'Δ', 'Ε' => 'Ε', 'Ζ' => 'Ζ', 'Η' => 'Η',
|
||||
'Θ' => 'Θ', 'Ι' => 'Ι', 'Κ' => 'Κ', 'Λ' => 'Λ', 'Μ' => 'Μ', 'Ν' => 'Ν', 'Ξ' => 'Ξ', 'Ο' => 'Ο',
|
||||
'Π' => 'Π', 'Ρ' => 'Ρ', 'Σ' => 'Σ', 'Τ' => 'Τ', 'Υ' => 'Υ', 'Φ' => 'Φ', 'Χ' => 'Χ', 'Ψ' => 'Ψ',
|
||||
'Ω' => 'Ω', 'α' => 'α', 'β' => 'β', 'γ' => 'γ', 'δ' => 'δ', 'ε' => 'ε', 'ζ' => 'ζ', 'η' => 'η',
|
||||
'θ' => 'θ', 'ι' => 'ι', 'κ' => 'κ', 'λ' => 'λ', 'μ' => 'μ', 'ν' => 'ν', 'ξ' => 'ξ', 'ο' => 'ο',
|
||||
'π' => 'π', 'ρ' => 'ρ', 'ς' => 'ς', 'σ' => 'σ', 'τ' => 'τ', 'υ' => 'υ', 'φ' => 'φ', 'χ' => 'χ',
|
||||
'ψ' => 'ψ', 'ω' => 'ω', 'ϑ' => 'ϑ', 'ϒ' => 'ϒ', 'ϖ' => 'ϖ', '•' => '•', '…' => '…', '′' => '′',
|
||||
'″' => '″', '‾' => '‾', '⁄' => '⁄', '℘' => '℘', 'ℑ' => 'ℑ', 'ℜ' => 'ℜ', '™' => '™', 'ℵ' => 'ℵ',
|
||||
'←' => '←', '↑' => '↑', '→' => '→', '↓' => '↓', '↔' => '↔', '↵' => '↵', '⇐' => '⇐', '⇑' => '⇑',
|
||||
'⇒' => '⇒', '⇓' => '⇓', '⇔' => '⇔', '∀' => '∀', '∂' => '∂', '∃' => '∃', '∅' => '∅', '∇' => '∇',
|
||||
'∈' => '∈', '∉' => '∉', '∋' => '∋', '∏' => '∏', '∑' => '∑', '−' => '−', '∗' => '∗', '√' => '√',
|
||||
'∝' => '∝', '∞' => '∞', '∠' => '∠', '∧' => '∧', '∨' => '∨', '∩' => '∩', '∪' => '∪', '∫' => '∫',
|
||||
'∴' => '∴', '∼' => '∼', '≅' => '≅', '≈' => '≈', '≠' => '≠', '≡' => '≡', '≤' => '≤', '≥' => '≥',
|
||||
'⊂' => '⊂', '⊃' => '⊃', '⊄' => '⊄', '⊆' => '⊆', '⊇' => '⊇', '⊕' => '⊕', '⊗' => '⊗', '⊥' => '⊥',
|
||||
'⋅' => '⋅', '⌈' => '⌈', '⌉' => '⌉', '⌊' => '⌊', '⌋' => '⌋', '⟨' => '〈', '⟩' => '〉', '◊' => '◊',
|
||||
'♠' => '♠', '♣' => '♣', '♥' => '♥', '♦' => '♦', '"' => '"', '&' => '&', '<' => '<', '>' => '>', 'Œ' => 'Œ',
|
||||
'œ' => 'œ', 'Š' => 'Š', 'š' => 'š', 'Ÿ' => 'Ÿ', 'ˆ' => 'ˆ', '˜' => '˜', ' ' => ' ', ' ' => ' ',
|
||||
' ' => ' ', '‌' => '‌', '‍' => '‍', '‎' => '‎', '‏' => '‏', '–' => '–', '—' => '—', '‘' => '‘',
|
||||
'’' => '’', '‚' => '‚', '“' => '“', '”' => '”', '„' => '„', '†' => '†', '‡' => '‡', '‰' => '‰',
|
||||
'‹' => '‹', '›' => '›', '€' => '€'
|
||||
];
|
||||
|
||||
/**
|
||||
* Closing string for void tags
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public static $void = ' />';
|
||||
|
||||
/**
|
||||
* Generates a single attribute or a list of attributes
|
||||
*
|
||||
* @param string|array $name String: A single attribute with that name will be generated.
|
||||
* Key-value array: A list of attributes will be generated. Don't pass a second argument in that case.
|
||||
* @param mixed $value If used with a `$name` string, pass the value of the attribute here.
|
||||
* If used with a `$name` array, this can be set to `false` to disable attribute sorting.
|
||||
* @return string|null The generated XML attributes string
|
||||
*/
|
||||
public static function attr(
|
||||
string|array $name,
|
||||
$value = null
|
||||
): string|null {
|
||||
if (is_array($name) === true) {
|
||||
if ($value !== false) {
|
||||
ksort($name);
|
||||
}
|
||||
|
||||
$attributes = [];
|
||||
foreach ($name as $key => $val) {
|
||||
if (is_int($key) === true) {
|
||||
$key = $val;
|
||||
$val = true;
|
||||
}
|
||||
|
||||
if ($attribute = static::attr($key, $val)) {
|
||||
$attributes[] = $attribute;
|
||||
}
|
||||
}
|
||||
|
||||
return implode(' ', $attributes);
|
||||
}
|
||||
|
||||
if ($value === null || $value === false || $value === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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 (is_array($value) === true) {
|
||||
if (isset($value['value'], $value['escape'])) {
|
||||
$value = $value['escape'] === true ? static::encode($value['value']) : $value['value'];
|
||||
} else {
|
||||
$value = implode(' ', array_filter(
|
||||
$value,
|
||||
fn ($value) => !empty($value) || is_numeric($value)
|
||||
));
|
||||
}
|
||||
} else {
|
||||
$value = static::encode($value);
|
||||
}
|
||||
|
||||
return $name . '="' . $value . '"';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an XML string from an array
|
||||
*
|
||||
* Supports special array keys `@name` (element name),
|
||||
* `@attributes` (XML attribute key-value array),
|
||||
* `@namespaces` (array with XML namespaces) and
|
||||
* `@value` (element content)
|
||||
*
|
||||
* @param array|string $props The source array or tag content (used internally)
|
||||
* @param string $name The name of the root element
|
||||
* @param bool $head Include the XML declaration head or not
|
||||
* @param string $indent Indentation string, defaults to two spaces
|
||||
* @param int $level The indentation level (used internally)
|
||||
* @return string The XML string
|
||||
*/
|
||||
public static function create(
|
||||
array|string $props,
|
||||
string $name = 'root',
|
||||
bool $head = true,
|
||||
string $indent = ' ',
|
||||
int $level = 0
|
||||
): string {
|
||||
if (is_array($props) === true) {
|
||||
if (A::isAssociative($props) === true) {
|
||||
// a tag with attributes or named children
|
||||
|
||||
// extract metadata from special array keys
|
||||
$name = $props['@name'] ?? $name;
|
||||
$attributes = $props['@attributes'] ?? [];
|
||||
$value = $props['@value'] ?? null;
|
||||
if (isset($props['@namespaces'])) {
|
||||
foreach ($props['@namespaces'] as $key => $namespace) {
|
||||
$key = 'xmlns' . (($key) ? ':' . $key : '');
|
||||
$attributes[$key] = $namespace;
|
||||
}
|
||||
}
|
||||
|
||||
// continue with just the children
|
||||
unset($props['@name'], $props['@attributes'], $props['@namespaces'], $props['@value']);
|
||||
|
||||
if (count($props) > 0) {
|
||||
// there are children, use them instead of the value
|
||||
|
||||
$value = [];
|
||||
foreach ($props as $childName => $childItem) {
|
||||
// render the child, but don't include the indentation of the first line
|
||||
$value[] = trim(static::create($childItem, $childName, false, $indent, $level + 1));
|
||||
}
|
||||
}
|
||||
|
||||
$result = static::tag($name, $value, $attributes, $indent, $level);
|
||||
} else {
|
||||
// just children
|
||||
|
||||
$result = [];
|
||||
foreach ($props as $childItem) {
|
||||
$result[] = static::create($childItem, $name, false, $indent, $level);
|
||||
}
|
||||
|
||||
$result = implode(PHP_EOL, $result);
|
||||
}
|
||||
} else {
|
||||
// scalar value
|
||||
|
||||
$result = static::tag($name, $props, [], $indent, $level);
|
||||
}
|
||||
|
||||
if ($head === true) {
|
||||
return '<?xml version="1.0" encoding="UTF-8"?>' . PHP_EOL . $result;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all HTML/XML tags and encoded chars from a string
|
||||
*
|
||||
* ```
|
||||
* echo Xml::decode('some über <em>crazy</em> stuff');
|
||||
* // output: some über crazy stuff
|
||||
* ```
|
||||
*/
|
||||
public static function decode(string|null $string): string
|
||||
{
|
||||
$string = strip_tags($string ?? '');
|
||||
return html_entity_decode($string, ENT_COMPAT, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a string to an XML-safe string
|
||||
*
|
||||
* Converts it to HTML-safe first and then it
|
||||
* will replace HTML entities with XML entities
|
||||
*
|
||||
* ```php
|
||||
* echo Xml::encode('some über crazy stuff');
|
||||
* // output: some über crazy stuff
|
||||
* ```
|
||||
*
|
||||
* @param bool $html True = Convert to HTML-safe first
|
||||
*/
|
||||
public static function encode(
|
||||
string|null $string,
|
||||
bool $html = true
|
||||
): string {
|
||||
if ($string === null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($html === true) {
|
||||
$string = Html::encode($string, false);
|
||||
}
|
||||
|
||||
$entities = self::entities();
|
||||
$html = array_keys($entities);
|
||||
$xml = array_values($entities);
|
||||
|
||||
return str_replace($html, $xml, $string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the HTML-to-XML entity translation table
|
||||
*/
|
||||
public static function entities(): array
|
||||
{
|
||||
return self::$entities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an XML string and returns an array
|
||||
*
|
||||
* @return array|null Parsed array or `null` on error
|
||||
*/
|
||||
public static function parse(string $xml): array|null
|
||||
{
|
||||
$xml = @simplexml_load_string($xml);
|
||||
|
||||
if (is_object($xml) !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return static::simplify($xml);
|
||||
}
|
||||
|
||||
/**
|
||||
* Breaks a SimpleXMLElement down into a simpler tree
|
||||
* structure of arrays and strings
|
||||
*
|
||||
* @param bool $collectName Whether the element name should be collected (for the root element)
|
||||
*/
|
||||
public static function simplify(
|
||||
SimpleXMLElement $element,
|
||||
bool $collectName = true
|
||||
): array|string {
|
||||
// get all XML namespaces of the whole document to iterate over later;
|
||||
// we don't need the global namespace (empty string) in the list
|
||||
$usedNamespaces = $element->getNamespaces(true);
|
||||
if (isset($usedNamespaces[''])) {
|
||||
unset($usedNamespaces['']);
|
||||
}
|
||||
|
||||
// now collect element metadata of the parent
|
||||
$array = [];
|
||||
if ($collectName === true) {
|
||||
$array['@name'] = $element->getName();
|
||||
}
|
||||
|
||||
// collect attributes with each defined document namespace;
|
||||
// also check for attributes without any namespace
|
||||
$attributeArray = [];
|
||||
foreach (array_merge([0 => null], array_keys($usedNamespaces)) as $namespace) {
|
||||
$prefix = ($namespace) ? $namespace . ':' : '';
|
||||
$attributes = $element->attributes($namespace, true);
|
||||
|
||||
foreach ($attributes as $key => $value) {
|
||||
$attributeArray[$prefix . $key] = (string)$value;
|
||||
}
|
||||
}
|
||||
if (count($attributeArray) > 0) {
|
||||
$array['@attributes'] = $attributeArray;
|
||||
}
|
||||
|
||||
// collect namespace definitions of this particular XML element
|
||||
if ($namespaces = $element->getDocNamespaces(false, false)) {
|
||||
$array['@namespaces'] = $namespaces;
|
||||
}
|
||||
|
||||
// check for children with each defined document namespace;
|
||||
// also check for children without any namespace
|
||||
$hasChildren = false;
|
||||
foreach (array_merge([0 => null], array_keys($usedNamespaces)) as $namespace) {
|
||||
$prefix = ($namespace) ? $namespace . ':' : '';
|
||||
$children = $element->children($namespace, true);
|
||||
|
||||
if (count($children) > 0) {
|
||||
// there are children, recursively simplify each one
|
||||
$hasChildren = true;
|
||||
|
||||
// make a grouped collection of elements per element name
|
||||
foreach ($children as $child) {
|
||||
$array[$prefix . $child->getName()][] = static::simplify($child, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasChildren === true) {
|
||||
// there were children of any namespace
|
||||
|
||||
// reduce elements where there is only one item
|
||||
// of the respective type to a simple string;
|
||||
// don't do anything with special `@` metadata keys
|
||||
foreach ($array as $name => $item) {
|
||||
if (substr($name, 0, 1) !== '@' && count($item) === 1) {
|
||||
$array[$name] = $item[0];
|
||||
}
|
||||
}
|
||||
|
||||
return $array;
|
||||
}
|
||||
|
||||
// we didn't find any XML children above, only use the string value
|
||||
$element = (string)$element;
|
||||
|
||||
if (count($array) === 0) {
|
||||
return $element;
|
||||
}
|
||||
|
||||
$array['@value'] = $element;
|
||||
return $array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an XML tag
|
||||
*
|
||||
* @param string $name Tag name
|
||||
* @param array|string|null $content Scalar value or array with multiple lines of content or `null` to
|
||||
* generate a self-closing tag; pass an empty string to generate empty content
|
||||
* @param array $attr An associative array with additional attributes for the tag
|
||||
* @param string|null $indent Indentation string, defaults to two spaces or `null` for output on one line
|
||||
* @param int $level Indentation level
|
||||
* @return string The generated XML
|
||||
*/
|
||||
public static function tag(
|
||||
string $name,
|
||||
array|string|null $content = '',
|
||||
array $attr = [],
|
||||
string $indent = null,
|
||||
int $level = 0
|
||||
): string {
|
||||
$attr = static::attr($attr);
|
||||
$start = '<' . $name . ($attr ? ' ' . $attr : '') . '>';
|
||||
$startShort = '<' . $name . ($attr ? ' ' . $attr : '') . static::$void;
|
||||
$end = '</' . $name . '>';
|
||||
$baseIndent = $indent ? str_repeat($indent, $level) : '';
|
||||
|
||||
if (is_array($content) === true) {
|
||||
if (is_string($indent) === true) {
|
||||
$xml = $baseIndent . $start . PHP_EOL;
|
||||
foreach ($content as $line) {
|
||||
$xml .= $baseIndent . $indent . $line . PHP_EOL;
|
||||
}
|
||||
$xml .= $baseIndent . $end;
|
||||
} else {
|
||||
$xml = $start . implode($content) . $end;
|
||||
}
|
||||
} elseif ($content === null) {
|
||||
$xml = $baseIndent . $startShort;
|
||||
} else {
|
||||
$xml = $baseIndent . $start . static::value($content) . $end;
|
||||
}
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Properly encodes tag contents
|
||||
*/
|
||||
public static function value($value): string|null
|
||||
{
|
||||
if ($value === true) {
|
||||
return 'true';
|
||||
}
|
||||
|
||||
if ($value === false) {
|
||||
return 'false';
|
||||
}
|
||||
|
||||
if (is_numeric($value) === true) {
|
||||
return (string)$value;
|
||||
}
|
||||
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Str::startsWith($value, '<![CDATA[') === true) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$encoded = htmlentities($value, ENT_NOQUOTES | ENT_XML1);
|
||||
if ($encoded === $value) {
|
||||
// no CDATA block needed
|
||||
return $value;
|
||||
}
|
||||
|
||||
// wrap everything in a CDATA block
|
||||
// and ensure that it is not closed in the input string
|
||||
return '<![CDATA[' . str_replace(']]>', ']]]]><![CDATA[>', $value) . ']]>';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user