Recipes for Food
-
- Edited by Nazli Ercan and Eric Li
- We are looking for recipes! Please submit one via email.
-
- Edited by Nazli Ercan and Eric Li
- We are looking for recipes! Please submit one via email.
-
' . print_r($variable, true) . ''; + } + + if ($echo === true) { + echo $output; + } + + return $output; + }, + + /** + * Modify URLs for file objects + * + * @param \Kirby\Cms\App $kirby Kirby instance + * @param \Kirby\Cms\File $file The original file object + * @return string + */ + 'file::url' => function (App $kirby, File $file): string { + return $file->mediaUrl(); + }, + + /** + * Adapt file characteristics + * + * @param \Kirby\Cms\App $kirby Kirby instance + * @param \Kirby\Cms\File|\Kirby\Cms\FileModifications $file The file object + * @param array $options All thumb options (width, height, crop, blur, grayscale) + * @return \Kirby\Cms\File|\Kirby\Cms\FileVersion + */ + 'file::version' => function (App $kirby, $file, array $options = []) { + if ($file->isResizable() === false) { + return $file; + } + + // create url and root + $mediaRoot = dirname($file->mediaRoot()); + $dst = $mediaRoot . '/{{ name }}{{ attributes }}.{{ extension }}'; + $thumbRoot = (new Filename($file->root(), $dst, $options))->toString(); + $thumbName = basename($thumbRoot); + $job = $mediaRoot . '/.jobs/' . $thumbName . '.json'; + + if (file_exists($thumbRoot) === false) { + try { + Data::write($job, array_merge($options, [ + 'filename' => $file->filename() + ])); + } catch (Throwable $e) { + return $file; + } + } + + return new FileVersion([ + 'modifications' => $options, + 'original' => $file, + 'root' => $thumbRoot, + 'url' => dirname($file->mediaUrl()) . '/' . $thumbName, + ]); + }, + + /** + * Used by the `js()` helper + * + * @param \Kirby\Cms\App $kirby Kirby instance + * @param string $url Relative or absolute URL + * @param string|array $options An array of attributes for the link tag or a media attribute string + */ + 'js' => function (App $kirby, string $url, $options = null): string { + return $url; + }, + + /** + * Add your own Markdown parser + * + * @param \Kirby\Cms\App $kirby Kirby instance + * @param string $text Text to parse + * @param array $options Markdown options + * @param bool $inline Whether to wrap the text in `
` tags
+ * @return string
+ */
+ 'markdown' => function (App $kirby, string $text = null, array $options = [], bool $inline = false): string {
+ static $markdown;
+ static $config;
+
+ // if the config options have changed or the component is called for the first time,
+ // (re-)initialize the parser object
+ if ($config !== $options) {
+ $markdown = new Markdown($options);
+ $config = $options;
+ }
+
+ return $markdown->parse($text, $inline);
+ },
+
+ /**
+ * Add your own SmartyPants parser
+ *
+ * @param \Kirby\Cms\App $kirby Kirby instance
+ * @param string $text Text to parse
+ * @param array $options SmartyPants options
+ * @return string
+ */
+ 'smartypants' => function (App $kirby, string $text = null, array $options = []): string {
+ static $smartypants;
+ static $config;
+
+ // if the config options have changed or the component is called for the first time,
+ // (re-)initialize the parser object
+ if ($config !== $options) {
+ $smartypants = new Smartypants($options);
+ $config = $options;
+ }
+
+ return $smartypants->parse($text);
+ },
+
+ /**
+ * Add your own snippet loader
+ *
+ * @param \Kirby\Cms\App $kirby Kirby instance
+ * @param string|array $name Snippet name
+ * @param array $data Data array for the snippet
+ * @return string|null
+ */
+ 'snippet' => function (App $kirby, $name, array $data = []): ?string {
+ $snippets = A::wrap($name);
+
+ foreach ($snippets as $name) {
+ $name = (string)$name;
+ $file = $kirby->root('snippets') . '/' . $name . '.php';
+
+ if (file_exists($file) === false) {
+ $file = $kirby->extensions('snippets')[$name] ?? null;
+ }
+
+ if ($file) {
+ break;
+ }
+ }
+
+ return Snippet::load($file, $data);
+ },
+
+ /**
+ * Add your own template engine
+ *
+ * @param \Kirby\Cms\App $kirby Kirby instance
+ * @param string $name Template name
+ * @param string $type Extension type
+ * @param string $defaultType Default extension type
+ * @return \Kirby\Cms\Template
+ */
+ 'template' => function (App $kirby, string $name, string $type = 'html', string $defaultType = 'html') {
+ return new Template($name, $type, $defaultType);
+ },
+
+ /**
+ * Add your own thumb generator
+ *
+ * @param \Kirby\Cms\App $kirby Kirby instance
+ * @param string $src The root of the original file
+ * @param string $dst The root to the desired destination
+ * @param array $options All thumb options that should be applied: `width`, `height`, `crop`, `blur`, `grayscale`
+ * @return string
+ */
+ 'thumb' => function (App $kirby, string $src, string $dst, array $options): string {
+ $darkroom = Darkroom::factory(option('thumbs.driver', 'gd'), option('thumbs', []));
+ $options = $darkroom->preprocess($src, $options);
+ $root = (new Filename($src, $dst, $options))->toString();
+
+ F::copy($src, $root, true);
+ $darkroom->process($root, $options);
+
+ return $root;
+ },
+
+ /**
+ * Modify all URLs
+ *
+ * @param \Kirby\Cms\App $kirby Kirby instance
+ * @param string $path URL path
+ * @param array|null $options Array of options for the Uri class
+ * @param Closure $originalHandler Callback function to the original URL handler with `$path` and `$options` as parameters
+ * @return string
+ */
+ 'url' => function (App $kirby, string $path = null, $options = [], Closure $originalHandler): string {
+ return $originalHandler($path, $options);
+ },
+
+];
diff --git a/kirby/config/fields.php b/kirby/config/fields.php
new file mode 100755
index 0000000..04fb325
--- /dev/null
+++ b/kirby/config/fields.php
@@ -0,0 +1,27 @@
+ __DIR__ . '/fields/checkboxes.php',
+ 'date' => __DIR__ . '/fields/date.php',
+ 'email' => __DIR__ . '/fields/email.php',
+ 'files' => __DIR__ . '/fields/files.php',
+ 'headline' => __DIR__ . '/fields/headline.php',
+ 'hidden' => __DIR__ . '/fields/hidden.php',
+ 'info' => __DIR__ . '/fields/info.php',
+ 'line' => __DIR__ . '/fields/line.php',
+ 'multiselect' => __DIR__ . '/fields/multiselect.php',
+ 'number' => __DIR__ . '/fields/number.php',
+ 'pages' => __DIR__ . '/fields/pages.php',
+ 'radio' => __DIR__ . '/fields/radio.php',
+ 'range' => __DIR__ . '/fields/range.php',
+ 'select' => __DIR__ . '/fields/select.php',
+ 'structure' => __DIR__ . '/fields/structure.php',
+ 'tags' => __DIR__ . '/fields/tags.php',
+ 'tel' => __DIR__ . '/fields/tel.php',
+ 'text' => __DIR__ . '/fields/text.php',
+ 'textarea' => __DIR__ . '/fields/textarea.php',
+ 'time' => __DIR__ . '/fields/time.php',
+ 'toggle' => __DIR__ . '/fields/toggle.php',
+ 'url' => __DIR__ . '/fields/url.php',
+ 'users' => __DIR__ . '/fields/users.php'
+];
diff --git a/kirby/config/fields/checkboxes.php b/kirby/config/fields/checkboxes.php
new file mode 100755
index 0000000..6837b45
--- /dev/null
+++ b/kirby/config/fields/checkboxes.php
@@ -0,0 +1,61 @@
+ ['min', 'options'],
+ 'props' => [
+ /**
+ * Unset inherited props
+ */
+ 'after' => null,
+ 'before' => null,
+ 'icon' => null,
+ 'placeholder' => null,
+
+ /**
+ * Arranges the checkboxes in the given number of columns
+ */
+ 'columns' => function (int $columns = 1) {
+ return $columns;
+ },
+ /**
+ * Default value for the field, which will be used when a page/file/user is created
+ */
+ 'default' => function ($default = null) {
+ return Str::split($default, ',');
+ },
+ /**
+ * Maximum number of checked boxes
+ */
+ 'max' => function (int $max = null) {
+ return $max;
+ },
+ /**
+ * Minimum number of checked boxes
+ */
+ 'min' => function (int $min = null) {
+ return $min;
+ },
+ 'value' => function ($value = null) {
+ return Str::split($value, ',');
+ },
+ ],
+ 'computed' => [
+ 'default' => function () {
+ return $this->sanitizeOptions($this->default);
+ },
+ 'value' => function () {
+ return $this->sanitizeOptions($this->value);
+ },
+ ],
+ 'save' => function ($value): string {
+ return A::join($value, ', ');
+ },
+ 'validations' => [
+ 'options',
+ 'max',
+ 'min'
+ ]
+];
diff --git a/kirby/config/fields/date.php b/kirby/config/fields/date.php
new file mode 100755
index 0000000..cbf4c55
--- /dev/null
+++ b/kirby/config/fields/date.php
@@ -0,0 +1,129 @@
+ [
+ /**
+ * Default date when a new page/file/user gets created
+ */
+ 'default' => function ($default = null) {
+ return $default;
+ },
+
+ /**
+ * Defines a custom format that is used when the field is saved
+ */
+ 'format' => function (string $format = null) {
+ return $format;
+ },
+
+ /**
+ * Changes the calendar icon to something custom
+ */
+ 'icon' => function (string $icon = 'calendar') {
+ return $icon;
+ },
+ /**
+ * Youngest date, which can be selected/saved
+ */
+ 'max' => function (string $max = null) {
+ return $this->toDate($max);
+ },
+ /**
+ * Oldest date, which can be selected/saved
+ */
+ 'min' => function (string $min = null) {
+ return $this->toDate($min);
+ },
+ /**
+ * The placeholder is not available
+ */
+ 'placeholder' => null,
+ /**
+ * Pass `true` or an array of time field options to show the time selector.
+ */
+ 'time' => function ($time = false) {
+ return $time;
+ },
+ /**
+ * Must be a parseable date string
+ */
+ 'value' => function ($value = null) {
+ return $value;
+ },
+ ],
+ 'computed' => [
+ 'default' => function () {
+ return $this->toDate($this->default);
+ },
+ 'format' => function () {
+ return $this->props['format'] ?? ($this->time() === false ? 'Y-m-d' : 'Y-m-d H:i');
+ },
+ 'value' => function () {
+ return $this->toDate($this->value);
+ },
+ ],
+ 'methods' => [
+ 'toDate' => function ($value) {
+ if ($timestamp = timestamp($value, $this->time['step'] ?? 5)) {
+ return date('Y-m-d H:i:s', $timestamp);
+ }
+
+ return null;
+ }
+ ],
+ 'save' => function ($value) {
+ if ($value !== null && $date = strtotime($value)) {
+ return date($this->format(), $date);
+ }
+
+ return '';
+ },
+ 'validations' => [
+ 'date',
+ 'minMax' => function ($value) {
+ $min = $this->min ? strtotime($this->min) : null;
+ $max = $this->max ? strtotime($this->max) : null;
+ $value = strtotime($this->value());
+ $format = 'd.m.Y';
+ $errors = [];
+
+ if ($value && $min && $value < $min) {
+ $errors['min'] = $min;
+ }
+
+ if ($value && $max && $value > $max) {
+ $errors['max'] = $max;
+ }
+
+ if (empty($errors) === false) {
+ if ($min && $max) {
+ throw new Exception([
+ 'key' => 'validation.date.between',
+ 'data' => [
+ 'min' => date($format, $min),
+ 'max' => date($format, $max)
+ ]
+ ]);
+ } elseif ($min) {
+ throw new Exception([
+ 'key' => 'validation.date.after',
+ 'data' => [
+ 'date' => date($format, $min),
+ ]
+ ]);
+ } else {
+ throw new Exception([
+ 'key' => 'validation.date.before',
+ 'data' => [
+ 'date' => date($format, $max),
+ ]
+ ]);
+ }
+ }
+
+ return true;
+ },
+ ]
+];
diff --git a/kirby/config/fields/email.php b/kirby/config/fields/email.php
new file mode 100755
index 0000000..e7892b8
--- /dev/null
+++ b/kirby/config/fields/email.php
@@ -0,0 +1,40 @@
+ 'text',
+ 'props' => [
+ /**
+ * Unset inherited props
+ */
+ 'converter' => null,
+ 'counter' => null,
+
+ /**
+ * Sets the HTML5 autocomplete mode for the input
+ */
+ 'autocomplete' => function (string $autocomplete = 'email') {
+ return $autocomplete;
+ },
+
+ /**
+ * Changes the email icon to something custom
+ */
+ 'icon' => function (string $icon = 'email') {
+ return $icon;
+ },
+
+ /**
+ * Custom placeholder text, when the field is empty.
+ */
+ 'placeholder' => function ($value = null) {
+ return I18n::translate($value, $value) ?? I18n::translate('email.placeholder');
+ }
+ ],
+ 'validations' => [
+ 'minlength',
+ 'maxlength',
+ 'email'
+ ]
+];
diff --git a/kirby/config/fields/files.php b/kirby/config/fields/files.php
new file mode 100755
index 0000000..9ecb0c1
--- /dev/null
+++ b/kirby/config/fields/files.php
@@ -0,0 +1,138 @@
+ [
+ 'picker',
+ 'filepicker',
+ 'min',
+ 'upload'
+ ],
+ 'props' => [
+ /**
+ * Unset inherited props
+ */
+ 'after' => null,
+ 'before' => null,
+ 'autofocus' => null,
+ 'icon' => null,
+ 'placeholder' => null,
+
+ /**
+ * Sets the file(s), which are selected by default when a new page is created
+ */
+ 'default' => function ($default = null) {
+ return $default;
+ },
+
+ /**
+ * Changes the layout of the selected files. Available layouts: `list`, `cards`
+ */
+ 'layout' => function (string $layout = 'list') {
+ return $layout;
+ },
+
+ /**
+ * Layout size for cards: `tiny`, `small`, `medium`, `large` or `huge`
+ */
+ 'size' => function (string $size = 'auto') {
+ return $size;
+ },
+
+ 'value' => function ($value = null) {
+ return $value;
+ }
+ ],
+ 'computed' => [
+ 'parentModel' => function () {
+ if (is_string($this->parent) === true && $model = $this->model()->query($this->parent, 'Kirby\Cms\Model')) {
+ return $model;
+ }
+
+ return $this->model();
+ },
+ 'parent' => function () {
+ return $this->parentModel->apiUrl(true);
+ },
+ 'query' => function () {
+ return $this->query ?? $this->parentModel::CLASS_ALIAS . '.files';
+ },
+ 'default' => function () {
+ return $this->toFiles($this->default);
+ },
+ 'value' => function () {
+ return $this->toFiles($this->value);
+ },
+ ],
+ 'methods' => [
+ 'fileResponse' => function ($file) {
+ return $file->panelPickerData([
+ 'image' => $this->image,
+ 'info' => $this->info ?? false,
+ 'model' => $this->model(),
+ 'text' => $this->text,
+ ]);
+ },
+ 'toFiles' => function ($value = null) {
+ $files = [];
+
+ foreach (Yaml::decode($value) as $id) {
+ if (is_array($id) === true) {
+ $id = $id['id'] ?? null;
+ }
+
+ if ($id !== null && ($file = $this->kirby()->file($id, $this->model()))) {
+ $files[] = $this->fileResponse($file);
+ }
+ }
+
+ return $files;
+ }
+ ],
+ 'api' => function () {
+ return [
+ [
+ 'pattern' => '/',
+ 'action' => function () {
+ $field = $this->field();
+
+ return $field->filepicker([
+ 'image' => $field->image(),
+ 'info' => $field->info(),
+ 'limit' => $field->limit(),
+ 'page' => $this->requestQuery('page'),
+ 'query' => $field->query(),
+ 'search' => $this->requestQuery('search'),
+ 'text' => $field->text()
+ ]);
+ }
+ ],
+ [
+ 'pattern' => 'upload',
+ 'method' => 'POST',
+ 'action' => function () {
+ $field = $this->field();
+ $uploads = $field->uploads();
+
+ return $field->upload($this, $uploads, function ($file, $parent) use ($field) {
+ return $file->panelPickerData([
+ 'image' => $field->image(),
+ 'info' => $field->info(),
+ 'model' => $field->model(),
+ 'text' => $field->text(),
+ ]);
+ });
+ }
+ ]
+ ];
+ },
+ 'save' => function ($value = null) {
+ return A::pluck($value, 'uuid');
+ },
+ 'validations' => [
+ 'max',
+ 'min'
+ ]
+];
diff --git a/kirby/config/fields/headline.php b/kirby/config/fields/headline.php
new file mode 100755
index 0000000..9b52938
--- /dev/null
+++ b/kirby/config/fields/headline.php
@@ -0,0 +1,27 @@
+ false,
+ 'props' => [
+ /**
+ * Unset inherited props
+ */
+ 'after' => null,
+ 'autofocus' => null,
+ 'before' => null,
+ 'default' => null,
+ 'disabled' => null,
+ 'help' => null,
+ 'icon' => null,
+ 'placeholder' => null,
+ 'required' => null,
+ 'translate' => null,
+
+ /**
+ * If `false`, the prepended number will be hidden
+ */
+ 'numbered' => function (bool $numbered = true) {
+ return $numbered;
+ }
+ ]
+];
diff --git a/kirby/config/fields/hidden.php b/kirby/config/fields/hidden.php
new file mode 100755
index 0000000..0b67a5f
--- /dev/null
+++ b/kirby/config/fields/hidden.php
@@ -0,0 +1,3 @@
+ [
+ /**
+ * Text to be displayed
+ */
+ 'text' => function ($value = null) {
+ return I18n::translate($value, $value);
+ },
+ ],
+ 'computed' => [
+ 'text' => function () {
+ if ($text = $this->text) {
+ $text = $this->model()->toString($text);
+ $text = $this->kirby()->kirbytext($text);
+ return $text;
+ }
+ }
+ ],
+ 'save' => false,
+];
diff --git a/kirby/config/fields/line.php b/kirby/config/fields/line.php
new file mode 100755
index 0000000..6844d6c
--- /dev/null
+++ b/kirby/config/fields/line.php
@@ -0,0 +1,5 @@
+ false
+];
diff --git a/kirby/config/fields/mixins/filepicker.php b/kirby/config/fields/mixins/filepicker.php
new file mode 100755
index 0000000..ba81230
--- /dev/null
+++ b/kirby/config/fields/mixins/filepicker.php
@@ -0,0 +1,14 @@
+ [
+ 'filepicker' => function (array $params = []) {
+ // fetch the parent model
+ $params['model'] = $this->model();
+
+ return (new FilePicker($params))->toArray();
+ }
+ ]
+];
diff --git a/kirby/config/fields/mixins/min.php b/kirby/config/fields/mixins/min.php
new file mode 100755
index 0000000..33e24d4
--- /dev/null
+++ b/kirby/config/fields/mixins/min.php
@@ -0,0 +1,22 @@
+ [
+ 'min' => function () {
+ // set min to at least 1, if required
+ if ($this->required === true) {
+ return $this->min ?? 1;
+ }
+
+ return $this->min;
+ },
+ 'required' => function () {
+ // set required to true if min is set
+ if ($this->min) {
+ return true;
+ }
+
+ return $this->required;
+ }
+ ]
+];
diff --git a/kirby/config/fields/mixins/options.php b/kirby/config/fields/mixins/options.php
new file mode 100755
index 0000000..170761a
--- /dev/null
+++ b/kirby/config/fields/mixins/options.php
@@ -0,0 +1,48 @@
+ [
+ /**
+ * API settings for options requests. This will only take affect when `options` is set to `api`.
+ */
+ 'api' => function ($api = null) {
+ return $api;
+ },
+ /**
+ * An array with options
+ */
+ 'options' => function ($options = []) {
+ return $options;
+ },
+ /**
+ * Query settings for options queries. This will only take affect when `options` is set to `query`.
+ */
+ 'query' => function ($query = null) {
+ return $query;
+ },
+ ],
+ 'computed' => [
+ 'options' => function (): array {
+ return $this->getOptions();
+ }
+ ],
+ 'methods' => [
+ 'getOptions' => function () {
+ return Options::factory(
+ $this->options(),
+ $this->props,
+ $this->model()
+ );
+ },
+ 'sanitizeOption' => function ($option) {
+ $allowed = array_column($this->options(), 'value');
+ return in_array($option, $allowed, true) === true ? $option : null;
+ },
+ 'sanitizeOptions' => function ($options) {
+ $allowed = array_column($this->options(), 'value');
+ return array_intersect($options, $allowed);
+ },
+ ]
+];
diff --git a/kirby/config/fields/mixins/pagepicker.php b/kirby/config/fields/mixins/pagepicker.php
new file mode 100755
index 0000000..bbdc86e
--- /dev/null
+++ b/kirby/config/fields/mixins/pagepicker.php
@@ -0,0 +1,14 @@
+ [
+ 'pagepicker' => function (array $params = []) {
+ // inject the current model
+ $params['model'] = $this->model();
+
+ return (new PagePicker($params))->toArray();
+ }
+ ]
+];
diff --git a/kirby/config/fields/mixins/picker.php b/kirby/config/fields/mixins/picker.php
new file mode 100755
index 0000000..a04ac95
--- /dev/null
+++ b/kirby/config/fields/mixins/picker.php
@@ -0,0 +1,71 @@
+ [
+ /**
+ * The placeholder text if none have been selected yet
+ */
+ 'empty' => function ($empty = null) {
+ return I18n::translate($empty, $empty);
+ },
+
+ /**
+ * Image settings for each item
+ */
+ 'image' => function ($image = null) {
+ return $image;
+ },
+
+ /**
+ * Info text for each item
+ */
+ 'info' => function (string $info = null) {
+ return $info;
+ },
+
+ /**
+ * The minimum number of required selected
+ */
+ 'min' => function (int $min = null) {
+ return $min;
+ },
+
+ /**
+ * The maximum number of allowed selected
+ */
+ 'max' => function (int $max = null) {
+ return $max;
+ },
+
+ /**
+ * If `false`, only a single one can be selected
+ */
+ 'multiple' => function (bool $multiple = true) {
+ return $multiple;
+ },
+
+ /**
+ * Query for the items to be included in the picker
+ */
+ 'query' => function (string $query = null) {
+ return $query;
+ },
+
+ /**
+ * Enable/disable the search field in the picker
+ */
+ 'search' => function (bool $search = true) {
+ return $search;
+ },
+
+ /**
+ * Main text for each item
+ */
+ 'text' => function (string $text = null) {
+ return $text;
+ },
+
+ ],
+];
diff --git a/kirby/config/fields/mixins/upload.php b/kirby/config/fields/mixins/upload.php
new file mode 100755
index 0000000..ce5bd4c
--- /dev/null
+++ b/kirby/config/fields/mixins/upload.php
@@ -0,0 +1,72 @@
+ [
+ /**
+ * Sets the upload options for linked files (since 3.2.0)
+ */
+ 'uploads' => function ($uploads = []) {
+ if ($uploads === false) {
+ return false;
+ }
+
+ if (is_string($uploads) === true) {
+ $uploads = ['template' => $uploads];
+ }
+
+ if (is_array($uploads) === false) {
+ $uploads = [];
+ }
+
+ $template = $uploads['template'] ?? null;
+
+ if ($template) {
+ $file = new File([
+ 'filename' => 'tmp',
+ 'template' => $template
+ ]);
+
+ $uploads['accept'] = $file->blueprint()->accept()['mime'] ?? '*';
+ } else {
+ $uploads['accept'] = '*';
+ }
+
+ return $uploads;
+ },
+ ],
+ 'methods' => [
+ 'upload' => function (Api $api, $params, Closure $map) {
+ if ($params === false) {
+ throw new Exception('Uploads are disabled for this field');
+ }
+
+ if ($parentQuery = ($params['parent'] ?? null)) {
+ $parent = $this->model()->query($parentQuery);
+ } else {
+ $parent = $this->model();
+ }
+
+ if (is_a($parent, 'Kirby\Cms\File') === true) {
+ $parent = $parent->parent();
+ }
+
+ return $api->upload(function ($source, $filename) use ($parent, $params, $map) {
+ $file = $parent->createFile([
+ 'source' => $source,
+ 'template' => $params['template'] ?? null,
+ 'filename' => $filename,
+ ]);
+
+ if (is_a($file, 'Kirby\Cms\File') === false) {
+ throw new Exception('The file could not be uploaded');
+ }
+
+ return $map($file, $parent);
+ });
+ }
+ ]
+];
diff --git a/kirby/config/fields/mixins/userpicker.php b/kirby/config/fields/mixins/userpicker.php
new file mode 100755
index 0000000..41c2b62
--- /dev/null
+++ b/kirby/config/fields/mixins/userpicker.php
@@ -0,0 +1,13 @@
+ [
+ 'userpicker' => function (array $params = []) {
+ $params['model'] = $this->model();
+
+ return (new UserPicker($params))->toArray();
+ }
+ ]
+];
diff --git a/kirby/config/fields/multiselect.php b/kirby/config/fields/multiselect.php
new file mode 100755
index 0000000..122d0c2
--- /dev/null
+++ b/kirby/config/fields/multiselect.php
@@ -0,0 +1,32 @@
+ 'tags',
+ 'props' => [
+
+ /**
+ * Unset inherited props
+ */
+ 'accept' => null,
+
+ /**
+ * Custom icon to replace the arrow down.
+ */
+ 'icon' => function (string $icon = null) {
+ return $icon;
+ },
+ /**
+ * Enable/disable the search in the dropdown
+ */
+ 'search' => function (bool $search = true) {
+ return $search;
+ },
+ /**
+ * If `true`, selected entries will be sorted
+ * according to their position in the dropdown
+ */
+ 'sort' => function (bool $sort = false) {
+ return $sort;
+ },
+ ]
+];
diff --git a/kirby/config/fields/number.php b/kirby/config/fields/number.php
new file mode 100755
index 0000000..6f78f77
--- /dev/null
+++ b/kirby/config/fields/number.php
@@ -0,0 +1,48 @@
+ [
+ /**
+ * Default number that will be saved when a new page/user/file is created
+ */
+ 'default' => function ($default = null) {
+ return $this->toNumber($default);
+ },
+ /**
+ * The lowest allowed number
+ */
+ 'min' => function (float $min = null) {
+ return $min;
+ },
+ /**
+ * The highest allowed number
+ */
+ 'max' => function (float $max = null) {
+ return $max;
+ },
+ /**
+ * Allowed incremental steps between numbers (i.e `0.5`)
+ */
+ 'step' => function ($step = null) {
+ return $this->toNumber($step);
+ },
+ 'value' => function ($value = null) {
+ return $this->toNumber($value);
+ }
+ ],
+ 'methods' => [
+ 'toNumber' => function ($value) {
+ if ($this->isEmpty($value) === true) {
+ return null;
+ }
+
+ return (float)Str::float($value);
+ }
+ ],
+ 'validations' => [
+ 'min',
+ 'max'
+ ]
+];
diff --git a/kirby/config/fields/pages.php b/kirby/config/fields/pages.php
new file mode 100755
index 0000000..9b4a3c2
--- /dev/null
+++ b/kirby/config/fields/pages.php
@@ -0,0 +1,117 @@
+ ['min', 'pagepicker', 'picker'],
+ 'props' => [
+ /**
+ * Unset inherited props
+ */
+ 'after' => null,
+ 'autofocus' => null,
+ 'before' => null,
+ 'icon' => null,
+ 'placeholder' => null,
+
+ /**
+ * Default selected page(s) when a new page/file/user is created
+ */
+ 'default' => function ($default = null) {
+ return $this->toPages($default);
+ },
+
+ /**
+ * Changes the layout of the selected files. Available layouts: `list`, `cards`
+ */
+ 'layout' => function (string $layout = 'list') {
+ return $layout;
+ },
+
+ /**
+ * Optional query to select a specific set of pages
+ */
+ 'query' => function (string $query = null) {
+ return $query;
+ },
+
+ /**
+ * Layout size for cards: `tiny`, `small`, `medium`, `large` or `huge`
+ */
+ 'size' => function (string $size = 'auto') {
+ return $size;
+ },
+
+ /**
+ * Optionally include subpages of pages
+ */
+ 'subpages' => function (bool $subpages = true) {
+ return $subpages;
+ },
+
+ 'value' => function ($value = null) {
+ return $this->toPages($value);
+ },
+ ],
+ 'computed' => [
+ /**
+ * Unset inherited computed
+ */
+ 'default' => null
+ ],
+ 'methods' => [
+ 'pageResponse' => function ($page) {
+ return $page->panelPickerData([
+ 'image' => $this->image,
+ 'info' => $this->info,
+ 'text' => $this->text,
+ ]);
+ },
+ 'toPages' => function ($value = null) {
+ $pages = [];
+ $kirby = kirby();
+
+ foreach (Yaml::decode($value) as $id) {
+ if (is_array($id) === true) {
+ $id = $id['id'] ?? null;
+ }
+
+ if ($id !== null && ($page = $kirby->page($id))) {
+ $pages[] = $this->pageResponse($page);
+ }
+ }
+
+ return $pages;
+ }
+ ],
+ 'api' => function () {
+ return [
+ [
+ 'pattern' => '/',
+ 'action' => function () {
+ $field = $this->field();
+
+ return $field->pagepicker([
+ 'image' => $field->image(),
+ 'info' => $field->info(),
+ 'limit' => $field->limit(),
+ 'page' => $this->requestQuery('page'),
+ 'parent' => $this->requestQuery('parent'),
+ 'query' => $field->query(),
+ 'search' => $this->requestQuery('search'),
+ 'subpages' => $field->subpages(),
+ 'text' => $field->text()
+ ]);
+ }
+ ]
+ ];
+ },
+ 'save' => function ($value = null) {
+ return A::pluck($value, 'id');
+ },
+ 'validations' => [
+ 'max',
+ 'min'
+ ]
+];
diff --git a/kirby/config/fields/radio.php b/kirby/config/fields/radio.php
new file mode 100755
index 0000000..dd9ffc3
--- /dev/null
+++ b/kirby/config/fields/radio.php
@@ -0,0 +1,29 @@
+ ['options'],
+ 'props' => [
+ /**
+ * Unset inherited props
+ */
+ 'after' => null,
+ 'before' => null,
+ 'icon' => null,
+ 'placeholder' => null,
+
+ /**
+ * Arranges the radio buttons in the given number of columns
+ */
+ 'columns' => function (int $columns = 1) {
+ return $columns;
+ },
+ ],
+ 'computed' => [
+ 'default' => function () {
+ return $this->sanitizeOption($this->default);
+ },
+ 'value' => function () {
+ return $this->sanitizeOption($this->value) ?? '';
+ }
+ ]
+];
diff --git a/kirby/config/fields/range.php b/kirby/config/fields/range.php
new file mode 100755
index 0000000..5f14388
--- /dev/null
+++ b/kirby/config/fields/range.php
@@ -0,0 +1,24 @@
+ 'number',
+ 'props' => [
+ /**
+ * Unset inherited props
+ */
+ 'placeholder' => null,
+
+ /**
+ * The maximum value on the slider
+ */
+ 'max' => function (float $max = 100) {
+ return $max;
+ },
+ /**
+ * Enables/disables the tooltip and set the before and after values
+ */
+ 'tooltip' => function ($tooltip = true) {
+ return $tooltip;
+ },
+ ]
+];
diff --git a/kirby/config/fields/select.php b/kirby/config/fields/select.php
new file mode 100755
index 0000000..24b14b6
--- /dev/null
+++ b/kirby/config/fields/select.php
@@ -0,0 +1,24 @@
+ 'radio',
+ 'props' => [
+ /**
+ * Unset inherited props
+ */
+ 'columns' => null,
+
+ /**
+ * Custom icon to replace the arrow down.
+ */
+ 'icon' => function (string $icon = null) {
+ return $icon;
+ },
+ /**
+ * Custom placeholder string for empty option.
+ */
+ 'placeholder' => function (string $placeholder = '—') {
+ return $placeholder;
+ },
+ ]
+];
diff --git a/kirby/config/fields/structure.php b/kirby/config/fields/structure.php
new file mode 100755
index 0000000..1eb27ba
--- /dev/null
+++ b/kirby/config/fields/structure.php
@@ -0,0 +1,179 @@
+ ['min'],
+ 'props' => [
+ /**
+ * Unset inherited props
+ */
+ 'after' => null,
+ 'before' => null,
+ 'autofocus' => null,
+ 'icon' => null,
+ 'placeholder' => null,
+
+ /**
+ * Optional columns definition to only show selected fields in the structure table.
+ */
+ 'columns' => function (array $columns = []) {
+ // lower case all keys, because field names will
+ // be lowercase as well.
+ return array_change_key_case($columns);
+ },
+ /**
+ * The placeholder text if no items have been added yet
+ */
+ 'empty' => function ($empty = null) {
+ return I18n::translate($empty, $empty);
+ },
+
+ /**
+ * Set the default rows for the structure
+ */
+ 'default' => function (array $default = null) {
+ return $default;
+ },
+
+ /**
+ * Fields setup for the structure form. Works just like fields in regular forms.
+ */
+ 'fields' => function (array $fields) {
+ return $fields;
+ },
+ /**
+ * The number of entries that will be displayed on a single page. Afterwards pagination kicks in.
+ */
+ 'limit' => function (int $limit = null) {
+ return $limit;
+ },
+ /**
+ * Maximum allowed entries in the structure. Afterwards the "Add" button will be switched off.
+ */
+ 'max' => function (int $max = null) {
+ return $max;
+ },
+ /**
+ * Minimum required entries in the structure
+ */
+ 'min' => function (int $min = null) {
+ return $min;
+ },
+ /**
+ * Toggles drag & drop sorting
+ */
+ 'sortable' => function (bool $sortable = null) {
+ return $sortable;
+ },
+ /**
+ * Sorts the entries by the given field and order (i.e. `title desc`)
+ * Drag & drop is disabled in this case
+ */
+ 'sortBy' => function (string $sort = null) {
+ return $sort;
+ }
+ ],
+ 'computed' => [
+ 'default' => function () {
+ return $this->rows($this->default);
+ },
+ 'value' => function () {
+ return $this->rows($this->value);
+ },
+ 'fields' => function () {
+ if (empty($this->fields) === true) {
+ throw new Exception('Please provide some fields for the structure');
+ }
+
+ return $this->form()->fields()->toArray();
+ },
+ 'columns' => function () {
+ $columns = [];
+
+ if (empty($this->columns)) {
+ foreach ($this->fields as $field) {
+
+ // Skip hidden fields.
+ // They should never be included as column
+ if ($field['type'] === 'hidden') {
+ continue;
+ }
+
+ $columns[$field['name']] = [
+ 'type' => $field['type'],
+ 'label' => $field['label'] ?? $field['name']
+ ];
+ }
+ } else {
+ foreach ($this->columns as $columnName => $columnProps) {
+ if (is_array($columnProps) === false) {
+ $columnProps = [];
+ }
+
+ $field = $this->fields[$columnName] ?? null;
+
+ if (empty($field) === true) {
+ continue;
+ }
+
+ $columns[$columnName] = array_merge($columnProps, [
+ 'type' => $field['type'],
+ 'label' => $field['label'] ?? $field['name']
+ ]);
+ }
+ }
+
+ return $columns;
+ }
+ ],
+ 'methods' => [
+ 'rows' => function ($value) {
+ $rows = Yaml::decode($value);
+ $value = [];
+
+ foreach ($rows as $index => $row) {
+ if (is_array($row) === false) {
+ continue;
+ }
+
+ $value[] = $this->form($row)->values();
+ }
+
+ return $value;
+ },
+ 'form' => function (array $values = []) {
+ return new Form([
+ 'fields' => $this->attrs['fields'],
+ 'values' => $values,
+ 'model' => $this->model
+ ]);
+ },
+ ],
+ 'api' => function () {
+ return [
+ [
+ 'pattern' => 'validate',
+ 'method' => 'ALL',
+ 'action' => function () {
+ return array_values($this->field()->form($this->requestBody())->errors());
+ }
+ ]
+ ];
+ },
+ 'save' => function ($value) {
+ $data = [];
+
+ foreach ($value as $row) {
+ $data[] = $this->form($row)->data();
+ }
+
+ return $data;
+ },
+ 'validations' => [
+ 'min',
+ 'max'
+ ]
+];
diff --git a/kirby/config/fields/tags.php b/kirby/config/fields/tags.php
new file mode 100755
index 0000000..93c29bd
--- /dev/null
+++ b/kirby/config/fields/tags.php
@@ -0,0 +1,96 @@
+ ['min', 'options'],
+ 'props' => [
+
+ /**
+ * Unset inherited props
+ */
+ 'after' => null,
+ 'before' => null,
+ 'placeholder' => null,
+
+ /**
+ * If set to `all`, any type of input is accepted. If set to `options` only the predefined options are accepted as input.
+ */
+ 'accept' => function ($value = 'all') {
+ return V::in($value, ['all', 'options']) ? $value : 'all';
+ },
+ /**
+ * Changes the tag icon
+ */
+ 'icon' => function ($icon = 'tag') {
+ return $icon;
+ },
+ /**
+ * Minimum number of required entries/tags
+ */
+ 'min' => function (int $min = null) {
+ return $min;
+ },
+ /**
+ * Maximum number of allowed entries/tags
+ */
+ 'max' => function (int $max = null) {
+ return $max;
+ },
+ /**
+ * Custom tags separator, which will be used to store tags in the content file
+ */
+ 'separator' => function (string $separator = ',') {
+ return $separator;
+ },
+ ],
+ 'computed' => [
+ 'default' => function (): array {
+ return $this->toTags($this->default);
+ },
+ 'value' => function (): array {
+ return $this->toTags($this->value);
+ }
+ ],
+ 'methods' => [
+ 'toTags' => function ($value) {
+ if (is_null($value) === true) {
+ return [];
+ }
+
+ $options = $this->options();
+
+ // transform into value-text objects
+ return array_map(function ($option) use ($options) {
+
+ // already a valid object
+ if (is_array($option) === true && isset($option['value'], $option['text']) === true) {
+ return $option;
+ }
+
+ $index = array_search($option, array_column($options, 'value'));
+
+ if ($index !== false) {
+ return $options[$index];
+ }
+
+ return [
+ 'value' => $option,
+ 'text' => $option,
+ ];
+ }, Str::split($value, $this->separator()));
+ }
+ ],
+ 'save' => function (array $value = null): string {
+ return A::join(
+ A::pluck($value, 'value'),
+ $this->separator() . ' '
+ );
+ },
+ 'validations' => [
+ 'min',
+ 'max'
+ ]
+];
diff --git a/kirby/config/fields/tel.php b/kirby/config/fields/tel.php
new file mode 100755
index 0000000..3d73430
--- /dev/null
+++ b/kirby/config/fields/tel.php
@@ -0,0 +1,27 @@
+ 'text',
+ 'props' => [
+ /**
+ * Unset inherited props
+ */
+ 'converter' => null,
+ 'counter' => null,
+ 'spellcheck' => null,
+
+ /**
+ * Sets the HTML5 autocomplete attribute
+ */
+ 'autocomplete' => function (string $autocomplete = 'tel') {
+ return $autocomplete;
+ },
+
+ /**
+ * Changes the phone icon
+ */
+ 'icon' => function (string $icon = 'phone') {
+ return $icon;
+ }
+ ]
+];
diff --git a/kirby/config/fields/text.php b/kirby/config/fields/text.php
new file mode 100755
index 0000000..c32a037
--- /dev/null
+++ b/kirby/config/fields/text.php
@@ -0,0 +1,103 @@
+ [
+
+ /**
+ * The field value will be converted with the selected converter before the value gets saved. Available converters: `lower`, `upper`, `ucfirst`, `slug`
+ */
+ 'converter' => function ($value = null) {
+ if ($value !== null && in_array($value, array_keys($this->converters())) === false) {
+ throw new InvalidArgumentException([
+ 'key' => 'field.converter.invalid',
+ 'data' => ['converter' => $value]
+ ]);
+ }
+
+ return $value;
+ },
+
+ /**
+ * Shows or hides the character counter in the top right corner
+ */
+ 'counter' => function (bool $counter = true) {
+ return $counter;
+ },
+
+ /**
+ * Maximum number of allowed characters
+ */
+ 'maxlength' => function (int $maxlength = null) {
+ return $maxlength;
+ },
+
+ /**
+ * Minimum number of required characters
+ */
+ 'minlength' => function (int $minlength = null) {
+ return $minlength;
+ },
+
+ /**
+ * A regular expression, which will be used to validate the input
+ */
+ 'pattern' => function (string $pattern = null) {
+ return $pattern;
+ },
+
+ /**
+ * If `false`, spellcheck will be switched off
+ */
+ 'spellcheck' => function (bool $spellcheck = false) {
+ return $spellcheck;
+ },
+ ],
+ 'computed' => [
+ 'default' => function () {
+ return $this->convert($this->default);
+ },
+ 'value' => function () {
+ return (string)$this->convert($this->value);
+ }
+ ],
+ 'methods' => [
+ 'convert' => function ($value) {
+ if ($this->converter() === null) {
+ return $value;
+ }
+
+ $value = trim($value);
+ $converter = $this->converters()[$this->converter()];
+
+ if (is_array($value) === true) {
+ return array_map($converter, $value);
+ }
+
+ return call_user_func($converter, $value);
+ },
+ 'converters' => function (): array {
+ return [
+ 'lower' => function ($value) {
+ return Str::lower($value);
+ },
+ 'slug' => function ($value) {
+ return Str::slug($value);
+ },
+ 'ucfirst' => function ($value) {
+ return Str::ucfirst($value);
+ },
+ 'upper' => function ($value) {
+ return Str::upper($value);
+ },
+ ];
+ },
+ ],
+ 'validations' => [
+ 'minlength',
+ 'maxlength',
+ 'pattern'
+ ]
+];
diff --git a/kirby/config/fields/textarea.php b/kirby/config/fields/textarea.php
new file mode 100755
index 0000000..cd23ddf
--- /dev/null
+++ b/kirby/config/fields/textarea.php
@@ -0,0 +1,122 @@
+ ['filepicker', 'upload'],
+ 'props' => [
+ /**
+ * Unset inherited props
+ */
+ 'after' => null,
+ 'before' => null,
+
+ /**
+ * Enables/disables the format buttons. Can either be `true`/`false` or a list of allowed buttons. Available buttons: `headlines`, `italic`, `bold`, `link`, `email`, `file`, `code`, `ul`, `ol` (as well as `|` for a divider)
+ */
+ 'buttons' => function ($buttons = true) {
+ return $buttons;
+ },
+
+ /**
+ * Enables/disables the character counter in the top right corner
+ */
+ 'counter' => function (bool $counter = true) {
+ return $counter;
+ },
+
+ /**
+ * Sets the default text when a new page/file/user is created
+ */
+ 'default' => function (string $default = null) {
+ return trim($default);
+ },
+
+ /**
+ * Sets the options for the files picker
+ */
+ 'files' => function ($files = []) {
+ if (is_string($files) === true) {
+ return ['query' => $files];
+ }
+
+ if (is_array($files) === false) {
+ $files = [];
+ }
+
+ return $files;
+ },
+
+ /**
+ * Sets the font family (sans or monospace)
+ */
+ 'font' => function (string $font = null) {
+ return $font === 'monospace' ? 'monospace' : 'sans-serif';
+ },
+
+ /**
+ * Maximum number of allowed characters
+ */
+ 'maxlength' => function (int $maxlength = null) {
+ return $maxlength;
+ },
+
+ /**
+ * Minimum number of required characters
+ */
+ 'minlength' => function (int $minlength = null) {
+ return $minlength;
+ },
+
+ /**
+ * Changes the size of the textarea. Available sizes: `small`, `medium`, `large`, `huge`
+ */
+ 'size' => function (string $size = null) {
+ return $size;
+ },
+
+ /**
+ * If `false`, spellcheck will be switched off
+ */
+ 'spellcheck' => function (bool $spellcheck = true) {
+ return $spellcheck;
+ },
+
+ 'value' => function (string $value = null) {
+ return trim($value);
+ }
+ ],
+ 'api' => function () {
+ return [
+ [
+ 'pattern' => 'files',
+ 'action' => function () {
+ $params = array_merge($this->field()->files(), [
+ 'page' => $this->requestQuery('page'),
+ 'search' => $this->requestQuery('search')
+ ]);
+
+ return $this->field()->filepicker($params);
+ }
+ ],
+ [
+ 'pattern' => 'upload',
+ 'action' => function () {
+ $field = $this->field();
+ $uploads = $field->uploads();
+
+ return $this->field()->upload($this, $uploads, function ($file, $parent) use ($field) {
+ $absolute = $field->model()->is($parent) === false;
+
+ return [
+ 'filename' => $file->filename(),
+ 'dragText' => $file->dragText('auto', $absolute),
+ ];
+ });
+ }
+ ]
+ ];
+ },
+ 'validations' => [
+ 'minlength',
+ 'maxlength'
+ ]
+];
diff --git a/kirby/config/fields/time.php b/kirby/config/fields/time.php
new file mode 100755
index 0000000..ef2fa23
--- /dev/null
+++ b/kirby/config/fields/time.php
@@ -0,0 +1,68 @@
+ [
+ /**
+ * Unset inherited props
+ */
+ 'placeholder' => null,
+
+ /**
+ * Sets the default time when a new page/file/user is created
+ */
+ 'default' => function ($default = null) {
+ return $default;
+ },
+ /**
+ * Changes the clock icon
+ */
+ 'icon' => function (string $icon = 'clock') {
+ return $icon;
+ },
+ /**
+ * `12` or `24` hour notation. If `12`, an AM/PM selector will be shown.
+ */
+ 'notation' => function (int $value = 24) {
+ return $value === 24 ? 24 : 12;
+ },
+ /**
+ * The interval between minutes in the minutes select dropdown.
+ */
+ 'step' => function (int $step = 5) {
+ return $step;
+ },
+ 'value' => function ($value = null) {
+ return $value;
+ }
+ ],
+ 'computed' => [
+ 'default' => function () {
+ return $this->toTime($this->default);
+ },
+ 'format' => function () {
+ return $this->notation === 24 ? 'H:i' : 'h:i a';
+ },
+ 'value' => function () {
+ return $this->toTime($this->value);
+ }
+ ],
+ 'methods' => [
+ 'toTime' => function ($value) {
+ if ($timestamp = timestamp($value, $this->step)) {
+ return date('H:i', $timestamp);
+ }
+
+ return null;
+ }
+ ],
+ 'save' => function ($value): string {
+ if ($timestamp = strtotime($value)) {
+ return date($this->format, $timestamp);
+ }
+
+ return '';
+ },
+ 'validations' => [
+ 'time',
+ ]
+];
diff --git a/kirby/config/fields/toggle.php b/kirby/config/fields/toggle.php
new file mode 100755
index 0000000..a43c613
--- /dev/null
+++ b/kirby/config/fields/toggle.php
@@ -0,0 +1,68 @@
+ [
+ /**
+ * Unset inherited props
+ */
+ 'placeholder' => null,
+
+ /**
+ * Default value which will be saved when a new page/user/file is created
+ */
+ 'default' => function ($default = null) {
+ return $this->default = $default;
+ },
+ /**
+ * Sets the text next to the toggle. The text can be a string or an array of two options. The first one is the negative text and the second one the positive. The text will automatically switch when the toggle is triggered.
+ */
+ 'text' => function ($value = null) {
+ if (is_array($value) === true) {
+ if (A::isAssociative($value) === true) {
+ return I18n::translate($value, $value);
+ }
+
+ foreach ($value as $key => $val) {
+ $value[$key] = I18n::translate($val, $val);
+ }
+
+ return $value;
+ }
+
+ return I18n::translate($value, $value);
+ },
+ ],
+ 'computed' => [
+ 'default' => function () {
+ return $this->toBool($this->default);
+ },
+ 'value' => function () {
+ if ($this->props['value'] === null) {
+ return $this->default();
+ } else {
+ return $this->toBool($this->props['value']);
+ }
+ }
+ ],
+ 'methods' => [
+ 'toBool' => function ($value) {
+ return in_array($value, [true, 'true', 1, '1', 'on'], true) === true;
+ }
+ ],
+ 'save' => function (): string {
+ return $this->value() === true ? 'true' : 'false';
+ },
+ 'validations' => [
+ 'boolean',
+ 'required' => function ($value) {
+ if ($this->isRequired() && ($value === false || $this->isEmpty($value))) {
+ throw new InvalidArgumentException([
+ 'key' => 'form.field.required'
+ ]);
+ }
+ },
+ ]
+];
diff --git a/kirby/config/fields/url.php b/kirby/config/fields/url.php
new file mode 100755
index 0000000..f92dd2c
--- /dev/null
+++ b/kirby/config/fields/url.php
@@ -0,0 +1,41 @@
+ 'text',
+ 'props' => [
+ /**
+ * Unset inherited props
+ */
+ 'converter' => null,
+ 'counter' => null,
+ 'spellcheck' => null,
+
+ /**
+ * Sets the HTML5 autocomplete attribute
+ */
+ 'autocomplete' => function (string $autocomplete = 'url') {
+ return $autocomplete;
+ },
+
+ /**
+ * Changes the link icon
+ */
+ 'icon' => function (string $icon = 'url') {
+ return $icon;
+ },
+
+ /**
+ * Sets custom placeholder text, when the field is empty
+ */
+ 'placeholder' => function ($value = null) {
+ return I18n::translate($value, $value) ?? 'https://example.com';
+ }
+ ],
+ 'validations' => [
+ 'minlength',
+ 'maxlength',
+ 'url'
+ ],
+];
diff --git a/kirby/config/fields/users.php b/kirby/config/fields/users.php
new file mode 100755
index 0000000..bc96bd4
--- /dev/null
+++ b/kirby/config/fields/users.php
@@ -0,0 +1,97 @@
+ ['min', 'picker', 'userpicker'],
+ 'props' => [
+ /**
+ * Unset inherited props
+ */
+ 'after' => null,
+ 'autofocus' => null,
+ 'before' => null,
+ 'icon' => null,
+ 'placeholder' => null,
+
+ /**
+ * Default selected user(s) when a new page/file/user is created
+ */
+ 'default' => function ($default = null) {
+ if ($default === false) {
+ return [];
+ }
+
+ if ($default === null && $user = $this->kirby()->user()) {
+ return [
+ $this->userResponse($user)
+ ];
+ }
+
+ return $this->toUsers($default);
+ },
+
+ 'value' => function ($value = null) {
+ return $this->toUsers($value);
+ },
+ ],
+ 'computed' => [
+ /**
+ * Unset inherited computed
+ */
+ 'default' => null
+ ],
+ 'methods' => [
+ 'userResponse' => function ($user) {
+ return $user->panelPickerData([
+ 'info' => $this->info,
+ 'image' => $this->image,
+ 'text' => $this->text,
+ ]);
+ },
+ 'toUsers' => function ($value = null) {
+ $users = [];
+ $kirby = kirby();
+
+ foreach (Yaml::decode($value) as $email) {
+ if (is_array($email) === true) {
+ $email = $email['email'] ?? null;
+ }
+
+ if ($email !== null && ($user = $kirby->user($email))) {
+ $users[] = $this->userResponse($user);
+ }
+ }
+
+ return $users;
+ }
+ ],
+ 'api' => function () {
+ return [
+ [
+ 'pattern' => '/',
+ 'action' => function () {
+ $field = $this->field();
+
+ return $field->userpicker([
+ 'image' => $field->image(),
+ 'info' => $field->info(),
+ 'limit' => $field->limit(),
+ 'page' => $this->requestQuery('page'),
+ 'query' => $field->query(),
+ 'search' => $this->requestQuery('search'),
+ 'text' => $field->text()
+ ]);
+ }
+ ]
+ ];
+ },
+ 'save' => function ($value = null) {
+ return A::pluck($value, 'id');
+ },
+ 'validations' => [
+ 'max',
+ 'min'
+ ]
+];
diff --git a/kirby/config/helpers.php b/kirby/config/helpers.php
new file mode 100755
index 0000000..23ca726
--- /dev/null
+++ b/kirby/config/helpers.php
@@ -0,0 +1,870 @@
+collection($name);
+}
+
+/**
+ * Checks / returns a CSRF token
+ *
+ * @param string $check Pass a token here to compare it to the one in the session
+ * @return string|bool Either the token or a boolean check result
+ */
+function csrf(string $check = null)
+{
+ $session = App::instance()->session();
+
+ // check explicitly if there have been no arguments at all;
+ // checking for null introduces a security issue because null could come
+ // from user input or bugs in the calling code!
+ if (func_num_args() === 0) {
+ // no arguments, generate/return a token
+
+ $token = $session->get('csrf');
+ if (is_string($token) !== true) {
+ $token = bin2hex(random_bytes(32));
+ $session->set('csrf', $token);
+ }
+
+ return $token;
+ } elseif (is_string($check) === true && is_string($session->get('csrf')) === true) {
+ // argument has been passed, check the token
+ return hash_equals($session->get('csrf'), $check) === true;
+ }
+
+ return false;
+}
+
+/**
+ * Creates one or multiple CSS link tags
+ *
+ * @param string|array $url Relative or absolute URLs, an array of URLs or `@auto` for automatic template css loading
+ * @param string|array $options Pass an array of attributes for the link tag or a media attribute string
+ * @return string|null
+ */
+function css($url, $options = null): ?string
+{
+ if (is_array($url) === true) {
+ $links = array_map(function ($url) use ($options) {
+ return css($url, $options);
+ }, $url);
+
+ return implode(PHP_EOL, $links);
+ }
+
+ if (is_string($options) === true) {
+ $options = ['media' => $options];
+ }
+
+ $kirby = App::instance();
+
+ if ($url === '@auto') {
+ if (!$url = Url::toTemplateAsset('css/templates', 'css')) {
+ return null;
+ }
+ }
+
+ $url = $kirby->component('css')($kirby, $url, $options);
+ $url = Url::to($url);
+ $attr = array_merge((array)$options, [
+ 'href' => $url,
+ 'rel' => 'stylesheet'
+ ]);
+
+ return '';
+}
+
+/**
+ * Triggers a deprecation warning if debug mode is active
+ * @since 3.3.0
+ *
+ * @param string $message
+ * @return bool Whether the warning was triggered
+ */
+function deprecated(string $message): bool
+{
+ if (App::instance()->option('debug') === true) {
+ return trigger_error($message, E_USER_DEPRECATED) === true;
+ }
+
+ return false;
+}
+
+/**
+ * Simple object and variable dumper
+ * to help with debugging.
+ *
+ * @param mixed $variable
+ * @param bool $echo
+ * @return string
+ */
+function dump($variable, bool $echo = true): string
+{
+ $kirby = App::instance();
+ return $kirby->component('dump')($kirby, $variable, $echo);
+}
+
+/**
+ * Smart version of echo with an if condition as first argument
+ *
+ * @param mixed $condition
+ * @param mixed $value The string to be echoed if the condition is true
+ * @param mixed $alternative An alternative string which should be echoed when the condition is false
+ */
+function e($condition, $value, $alternative = null)
+{
+ echo r($condition, $value, $alternative);
+}
+
+/**
+ * Escape context specific output
+ *
+ * @param string $string Untrusted data
+ * @param string $context Location of output
+ * @param bool $strict Whether to escape an extended set of characters (HTML attributes only)
+ * @return string Escaped data
+ */
+function esc($string, $context = 'html', $strict = false)
+{
+ if (method_exists('Kirby\Toolkit\Escape', $context) === true) {
+ return Escape::$context($string, $strict);
+ }
+
+ return $string;
+}
+
+
+/**
+ * Shortcut for $kirby->request()->get()
+ *
+ * @param mixed $key The key to look for. Pass false or null to return the entire request array.
+ * @param mixed $default Optional default value, which should be returned if no element has been found
+ * @return mixed
+ */
+function get($key = null, $default = null)
+{
+ return App::instance()->request()->get($key, $default);
+}
+
+/**
+ * Embeds a Github Gist
+ *
+ * @param string $url
+ * @param string $file
+ * @return string
+ */
+function gist(string $url, string $file = null): string
+{
+ return kirbytag([
+ 'gist' => $url,
+ 'file' => $file,
+ ]);
+}
+
+/**
+ * Redirects to the given Urls
+ * Urls can be relative or absolute.
+ *
+ * @param string $url
+ * @param int $code
+ * @return void
+ */
+function go(string $url = null, int $code = 302)
+{
+ die(Response::redirect($url, $code));
+}
+
+/**
+ * Shortcut for html()
+ *
+ * @param string $string unencoded text
+ * @param bool $keepTags
+ * @return string
+ */
+function h(string $string = null, bool $keepTags = false)
+{
+ return Html::encode($string, $keepTags);
+}
+
+/**
+ * Creates safe html by encoding special characters
+ *
+ * @param string $string unencoded text
+ * @param bool $keepTags
+ * @return string
+ */
+function html(string $string = null, bool $keepTags = false)
+{
+ return Html::encode($string, $keepTags);
+}
+
+/**
+ * Return an image from any page
+ * specified by the path
+ *
+ * Example:
+ * = image('some/page/myimage.jpg') ?>
+ *
+ * @param string $path
+ * @return \Kirby\Cms\File|null
+ */
+function image(string $path = null)
+{
+ if ($path === null) {
+ return page()->image();
+ }
+
+ $uri = dirname($path);
+ $filename = basename($path);
+
+ if ($uri === '.') {
+ $uri = null;
+ }
+
+ switch ($uri) {
+ case '/':
+ $parent = site();
+ break;
+ case null:
+ $parent = page();
+ break;
+ default:
+ $parent = page($uri);
+ break;
+ }
+
+ if ($parent) {
+ return $parent->image($filename);
+ } else {
+ return null;
+ }
+}
+
+/**
+ * Runs a number of validators on a set of data and checks if the data is invalid
+ *
+ * @param array $data
+ * @param array $rules
+ * @param array $messages
+ * @return false|array
+ */
+function invalid(array $data = [], array $rules = [], array $messages = [])
+{
+ $errors = [];
+
+ foreach ($rules as $field => $validations) {
+ $validationIndex = -1;
+
+ // See: http://php.net/manual/en/types.comparisons.php
+ // only false for: null, undefined variable, '', []
+ $filled = isset($data[$field]) && $data[$field] !== '' && $data[$field] !== [];
+ $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 (is_numeric($method) === true) {
+ $method = $options;
+ }
+
+ $validationIndex++;
+
+ if ($method === 'required') {
+ if ($filled) {
+ // Field is required and filled.
+ continue;
+ }
+ } elseif ($filled) {
+ if (is_array($options) === false) {
+ $options = [$options];
+ }
+
+ array_unshift($options, $data[$field] ?? null);
+
+ 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 script tag to load a javascript file
+ *
+ * @param string|array $url
+ * @param string|array $options
+ * @return string|null
+ */
+function js($url, $options = null): ?string
+{
+ if (is_array($url) === true) {
+ $scripts = array_map(function ($url) use ($options) {
+ return js($url, $options);
+ }, $url);
+
+ return implode(PHP_EOL, $scripts);
+ }
+
+ if (is_bool($options) === true) {
+ $options = ['async' => $options];
+ }
+
+ $kirby = App::instance();
+
+ if ($url === '@auto') {
+ if (!$url = Url::toTemplateAsset('js/templates', 'js')) {
+ return null;
+ }
+ }
+
+ $url = $kirby->component('js')($kirby, $url, $options);
+ $url = Url::to($url);
+ $attr = array_merge((array)$options, ['src' => $url]);
+
+ return '';
+}
+
+/**
+ * Returns the Kirby object in any situation
+ *
+ * @return \Kirby\Cms\App
+ */
+function kirby()
+{
+ return App::instance();
+}
+
+/**
+ * Makes it possible to use any defined Kirbytag as standalone function
+ *
+ * @param string|array $type
+ * @param string $value
+ * @param array $attr
+ * @return string
+ */
+function kirbytag($type, string $value = null, array $attr = []): string
+{
+ if (is_array($type) === true) {
+ return App::instance()->kirbytag(key($type), current($type), $type);
+ }
+
+ return App::instance()->kirbytag($type, $value, $attr);
+}
+
+/**
+ * Parses KirbyTags in the given string. Shortcut
+ * for `$kirby->kirbytags($text, $data)`
+ *
+ * @param string $text
+ * @param array $data
+ * @return string
+ */
+function kirbytags(string $text = null, array $data = []): string
+{
+ return App::instance()->kirbytags($text, $data);
+}
+
+/**
+ * Parses KirbyTags and Markdown in the
+ * given string. Shortcut for `$kirby->kirbytext()`
+ *
+ * @param string $text
+ * @param array $data
+ * @return string
+ */
+function kirbytext(string $text = null, array $data = []): string
+{
+ return App::instance()->kirbytext($text, $data);
+}
+
+/**
+ * Parses KirbyTags and inline Markdown in the
+ * given string.
+ * @since 3.1.0
+ *
+ * @param string $text
+ * @param array $data
+ * @return string
+ */
+function kirbytextinline(string $text = null, array $data = []): string
+{
+ return App::instance()->kirbytext($text, $data, true);
+}
+
+/**
+ * Shortcut for `kirbytext()` helper
+ *
+ * @param string $text
+ * @param array $data
+ * @return string
+ */
+function kt(string $text = null, array $data = []): string
+{
+ return kirbytext($text, $data);
+}
+
+/**
+ * Shortcut for `kirbytextinline()` helper
+ * @since 3.1.0
+ *
+ * @param string $text
+ * @param array $data
+ * @return string
+ */
+function kti(string $text = null, array $data = []): string
+{
+ return kirbytextinline($text, $data);
+}
+
+/**
+ * A super simple class autoloader
+ *
+ * @param array $classmap
+ * @param string $base
+ * @return void
+ */
+function load(array $classmap, string $base = null)
+{
+ // convert all classnames to lowercase
+ $classmap = array_change_key_case($classmap);
+
+ spl_autoload_register(function ($class) use ($classmap, $base) {
+ $class = strtolower($class);
+
+ if (!isset($classmap[$class])) {
+ return false;
+ }
+
+ if ($base) {
+ include $base . '/' . $classmap[$class];
+ } else {
+ include $classmap[$class];
+ }
+ });
+}
+
+/**
+ * Parses markdown in the given string. Shortcut for
+ * `$kirby->markdown($text)`
+ *
+ * @param string $text
+ * @return string
+ */
+function markdown(string $text = null): string
+{
+ return App::instance()->markdown($text);
+}
+
+/**
+ * Shortcut for `$kirby->option($key, $default)`
+ *
+ * @param string $key
+ * @param mixed $default
+ * @return mixed
+ */
+function option(string $key, $default = null)
+{
+ return App::instance()->option($key, $default);
+}
+
+/**
+ * Fetches a single page or multiple pages by
+ * id or the current page when no id is specified
+ *
+ * @param string|array ...$id
+ * @return \Kirby\Cms\Page|null
+ */
+function page(...$id)
+{
+ if (empty($id) === true) {
+ return App::instance()->site()->page();
+ }
+
+ return App::instance()->site()->find(...$id);
+}
+
+/**
+ * Helper to build page collections
+ *
+ * @param string|array ...$id
+ * @return \Kirby\Cms\Pages
+ */
+function pages(...$id)
+{
+ return App::instance()->site()->find(...$id);
+}
+
+/**
+ * Returns a single param from the URL
+ *
+ * @param string $key
+ * @param string $fallback
+ * @return string|null
+ */
+function param(string $key, string $fallback = null): ?string
+{
+ return App::instance()->request()->url()->params()->$key ?? $fallback;
+}
+
+/**
+ * Returns all params from the current Url
+ *
+ * @return array
+ */
+function params(): array
+{
+ return App::instance()->request()->url()->params()->toArray();
+}
+
+/**
+ * Smart version of return with an if condition as first argument
+ *
+ * @param mixed $condition
+ * @param mixed $value The string to be returned if the condition is true
+ * @param mixed $alternative An alternative string which should be returned when the condition is false
+ * @return mixed
+ */
+function r($condition, $value, $alternative = null)
+{
+ return $condition ? $value : $alternative;
+}
+
+/**
+ * Rounds the minutes of the given date
+ * by the defined step
+ *
+ * @param string $date
+ * @param int $step
+ * @return string|null
+ */
+function timestamp(string $date = null, int $step = null): ?string
+{
+ if (V::date($date) === false) {
+ return null;
+ }
+
+ $date = strtotime($date);
+
+ if ($step === null) {
+ return $date;
+ }
+
+ $hours = date('H', $date);
+ $minutes = date('i', $date);
+ $minutes = floor($minutes / $step) * $step;
+ $minutes = str_pad($minutes, 2, 0, STR_PAD_LEFT);
+ $date = date('Y-m-d', $date) . ' ' . $hours . ':' . $minutes;
+
+ return strtotime($date);
+}
+
+/**
+ * Returns the currrent site object
+ *
+ * @return \Kirby\Cms\Site
+ */
+function site()
+{
+ return App::instance()->site();
+}
+
+/**
+ * Determines the size/length of numbers, strings, arrays and countable objects
+ *
+ * @param mixed $value
+ * @return int
+ */
+function size($value): int
+{
+ if (is_numeric($value)) {
+ return $value;
+ }
+
+ if (is_string($value)) {
+ return Str::length(trim($value));
+ }
+
+ if (is_array($value)) {
+ return count($value);
+ }
+
+ if (is_object($value)) {
+ if (is_a($value, 'Countable') === true) {
+ return count($value);
+ }
+
+ if (is_a($value, 'Kirby\Toolkit\Collection') === true) {
+ return $value->count();
+ }
+ }
+}
+
+/**
+ * Enhances the given string with
+ * smartypants. Shortcut for `$kirby->smartypants($text)`
+ *
+ * @param string $text
+ * @return string
+ */
+function smartypants(string $text = null): string
+{
+ return App::instance()->smartypants($text);
+}
+
+/**
+ * Embeds a snippet from the snippet folder
+ *
+ * @param string|array $name
+ * @param array|object $data
+ * @param bool $return
+ * @return string
+ */
+function snippet($name, $data = [], bool $return = false)
+{
+ if (is_object($data) === true) {
+ $data = ['item' => $data];
+ }
+
+ $snippet = App::instance()->snippet($name, $data);
+
+ if ($return === true) {
+ return $snippet;
+ }
+
+ echo $snippet;
+}
+
+/**
+ * Includes an SVG file by absolute or
+ * relative file path.
+ *
+ * @param string|\Kirby\Cms\File $file
+ * @return string|false
+ */
+function svg($file)
+{
+ // support for Kirby's file objects
+ if (is_a($file, 'Kirby\Cms\File') === true && $file->extension() === 'svg') {
+ return $file->read();
+ }
+
+ if (is_string($file) === false) {
+ return false;
+ }
+
+ $extension = F::extension($file);
+
+ // check for valid svg files
+ if ($extension !== 'svg') {
+ return false;
+ }
+
+ // try to convert relative paths to absolute
+ if (file_exists($file) === false) {
+ $root = App::instance()->root();
+ $file = realpath($root . '/' . $file);
+
+ if (file_exists($file) === false) {
+ return false;
+ }
+ }
+
+ return F::read($file);
+}
+
+/**
+ * Returns translate string for key from translation file
+ *
+ * @param string|array $key
+ * @param string|null $fallback
+ * @return mixed
+ */
+function t($key, string $fallback = null)
+{
+ return I18n::translate($key, $fallback);
+}
+
+/**
+ * Translates a count
+ *
+ * @param string|array $key
+ * @param int $count
+ * @return mixed
+ */
+function tc($key, int $count)
+{
+ return I18n::translateCount($key, $count);
+}
+
+/**
+ * Translate by key and then replace
+ * placeholders in the text
+ *
+ * @param string $key
+ * @param string $fallback
+ * @param array $replace
+ * @param string $locale
+ * @return string
+ */
+function tt(string $key, $fallback = null, array $replace = null, string $locale = null)
+{
+ return I18n::template($key, $fallback, $replace, $locale);
+}
+
+/**
+ * Builds a Twitter link
+ *
+ * @param string $username
+ * @param string $text
+ * @param string $title
+ * @param string $class
+ * @return string
+ */
+function twitter(string $username, string $text = null, string $title = null, string $class = null): string
+{
+ return kirbytag([
+ 'twitter' => $username,
+ 'text' => $text,
+ 'title' => $title,
+ 'class' => $class
+ ]);
+}
+
+/**
+ * Shortcut for url()
+ *
+ * @param string $path
+ * @param array|string|null $options
+ * @return string
+ */
+function u(string $path = null, $options = null): string
+{
+ return Url::to($path, $options);
+}
+
+/**
+ * Builds an absolute URL for a given path
+ *
+ * @param string $path
+ * @param array|string|null $options
+ * @return string
+ */
+function url(string $path = null, $options = null): string
+{
+ return Url::to($path, $options);
+}
+
+/**
+ * Creates a video embed via iframe for Youtube or Vimeo
+ * videos. The embed Urls are automatically detected from
+ * the given Url.
+ *
+ * @param string $url
+ * @param array $options
+ * @param array $attr
+ * @return string
+ */
+function video(string $url, array $options = [], array $attr = []): string
+{
+ return Html::video($url, $options, $attr);
+}
+
+/**
+ * Embeds a Vimeo video by URL in an iframe
+ *
+ * @param string $url
+ * @param array $options
+ * @param array $attr
+ * @return string
+ */
+function vimeo(string $url, array $options = [], array $attr = []): string
+{
+ return Html::vimeo($url, $options, $attr);
+}
+
+/**
+ * The widont function makes sure that there are no
+ * typographical widows at the end of a paragraph –
+ * that's a single word in the last line
+ *
+ * @param string|null $string
+ * @return string
+ */
+function widont(string $string = null): string
+{
+ return Str::widont($string);
+}
+
+/**
+ * Embeds a Youtube video by URL in an iframe
+ *
+ * @param string $url
+ * @param array $options
+ * @param array $attr
+ * @return string
+ */
+function youtube(string $url, array $options = [], array $attr = []): string
+{
+ return Html::youtube($url, $options, $attr);
+}
diff --git a/kirby/config/methods.php b/kirby/config/methods.php
new file mode 100755
index 0000000..dc0d963
--- /dev/null
+++ b/kirby/config/methods.php
@@ -0,0 +1,520 @@
+ function (Field $field): bool {
+ return $field->toBool() === false;
+ },
+
+ /**
+ * Converts the field value into a proper boolean
+ *
+ * @param \Kirby\Cms\Field $field
+ * @return bool
+ */
+ 'isTrue' => function (Field $field): bool {
+ return $field->toBool() === true;
+ },
+
+ /**
+ * Validates the field content with the given validator and parameters
+ *
+ * @param string $validator
+ * @param mixed ...$arguments A list of optional validator arguments
+ * @return bool
+ */
+ 'isValid' => function (Field $field, string $validator, ...$arguments): bool {
+ return V::$validator($field->value, ...$arguments);
+ },
+
+ // converters
+
+ /**
+ * Parses the field value with the given method
+ *
+ * @param \Kirby\Cms\Field $field
+ * @param string $method [',', 'yaml', 'json']
+ * @return array
+ */
+ 'toData' => function (Field $field, string $method = ',') {
+ switch ($method) {
+ case 'yaml':
+ return Yaml::decode($field->value);
+ case 'json':
+ return Json::decode($field->value);
+ default:
+ return $field->split($method);
+ }
+ },
+
+ /**
+ * Converts the field value into a proper boolean
+ *
+ * @param \Kirby\Cms\Field $field
+ * @param bool $default Default value if the field is empty
+ * @return bool
+ */
+ 'toBool' => function (Field $field, $default = false): bool {
+ $value = $field->isEmpty() ? $default : $field->value;
+ return filter_var($value, FILTER_VALIDATE_BOOLEAN);
+ },
+
+ /**
+ * Converts the field value to a timestamp or a formatted date
+ *
+ * @param \Kirby\Cms\Field $field
+ * @param string|null $format PHP date formatting string
+ * @param string|null $fallback Fallback string for `strtotime` (since 3.2)
+ * @return string|int
+ */
+ 'toDate' => function (Field $field, string $format = null, string $fallback = null) use ($app) {
+ if (empty($field->value) === true && $fallback === null) {
+ return null;
+ }
+
+ $time = empty($field->value) === true ? strtotime($fallback) : $field->toTimestamp();
+
+ if ($format === null) {
+ return $time;
+ }
+
+ return $app->option('date.handler', 'date')($format, $time);
+ },
+
+ /**
+ * Returns a file object from a filename in the field
+ *
+ * @param \Kirby\Cms\Field $field
+ * @return \Kirby\Cms\File|null
+ */
+ 'toFile' => function (Field $field) {
+ return $field->toFiles()->first();
+ },
+
+ /**
+ * Returns a file collection from a yaml list of filenames in the field
+ *
+ * @param \Kirby\Cms\Field $field
+ * @param string $separator
+ * @return \Kirby\Cms\Files
+ */
+ 'toFiles' => function (Field $field, string $separator = 'yaml') {
+ $parent = $field->parent();
+ $files = new Files([]);
+
+ foreach ($field->toData($separator) as $id) {
+ if ($file = $parent->kirby()->file($id, $parent)) {
+ $files->add($file);
+ }
+ }
+
+ return $files;
+ },
+
+ /**
+ * Converts the field value into a proper float
+ *
+ * @param \Kirby\Cms\Field $field
+ * @param float $default Default value if the field is empty
+ * @return float
+ */
+ 'toFloat' => function (Field $field, float $default = 0) {
+ $value = $field->isEmpty() ? $default : $field->value;
+ return (float)$value;
+ },
+
+ /**
+ * Converts the field value into a proper integer
+ *
+ * @param \Kirby\Cms\Field $field
+ * @param int $default Default value if the field is empty
+ * @return int
+ */
+ 'toInt' => function (Field $field, int $default = 0) {
+ $value = $field->isEmpty() ? $default : $field->value;
+ return (int)$value;
+ },
+
+ /**
+ * Wraps a link tag around the field value. The field value is used as the link text
+ *
+ * @param \Kirby\Cms\Field $field
+ * @param mixed $attr1 Can be an optional Url. If no Url is set, the Url of the Page, File or Site will be used. Can also be an array of link attributes
+ * @param mixed $attr2 If `$attr1` is used to set the Url, you can use `$attr2` to pass an array of additional attributes.
+ * @return string
+ */
+ 'toLink' => function (Field $field, $attr1 = null, $attr2 = null) {
+ if (is_string($attr1) === true) {
+ $href = $attr1;
+ $attr = $attr2;
+ } else {
+ $href = $field->parent()->url();
+ $attr = $attr1;
+ }
+
+ if ($field->parent()->isActive()) {
+ $attr['aria-current'] = 'page';
+ }
+
+ return Html::a($href, $field->value, $attr ?? []);
+ },
+
+ /**
+ * Returns a page object from a page id in the field
+ *
+ * @param \Kirby\Cms\Field $field
+ * @return \Kirby\Cms\Page|null
+ */
+ 'toPage' => function (Field $field) {
+ return $field->toPages()->first();
+ },
+
+ /**
+ * Returns a pages collection from a yaml list of page ids in the field
+ *
+ * @param \Kirby\Cms\Field $field
+ * @param string $separator Can be any other separator to split the field value by
+ * @return \Kirby\Cms\Pages
+ */
+ 'toPages' => function (Field $field, string $separator = 'yaml') use ($app) {
+ return $app->site()->find(false, false, ...$field->toData($separator));
+ },
+
+ /**
+ * Converts a yaml field to a Structure object
+ *
+ * @param \Kirby\Cms\Field $field
+ * @return \Kirby\Cms\Structure
+ */
+ 'toStructure' => function (Field $field) {
+ try {
+ return new Structure(Yaml::decode($field->value), $field->parent());
+ } catch (Exception $e) {
+ if ($field->parent() === null) {
+ $message = 'Invalid structure data for "' . $field->key() . '" field';
+ } else {
+ $message = 'Invalid structure data for "' . $field->key() . '" field on parent "' . $field->parent()->title() . '"';
+ }
+
+ throw new InvalidArgumentException($message);
+ }
+ },
+
+ /**
+ * Converts the field value to a Unix timestamp
+ *
+ * @param \Kirby\Cms\Field $field
+ * @return int
+ */
+ 'toTimestamp' => function (Field $field): int {
+ return strtotime($field->value);
+ },
+
+ /**
+ * Turns the field value into an absolute Url
+ *
+ * @param \Kirby\Cms\Field $field
+ * @return string
+ */
+ 'toUrl' => function (Field $field): string {
+ return Url::to($field->value);
+ },
+
+ /**
+ * Converts a user email address to a user object
+ *
+ * @param \Kirby\Cms\Field $field
+ * @return \Kirby\Cms\User|null
+ */
+ 'toUser' => function (Field $field) {
+ return $field->toUsers()->first();
+ },
+
+ /**
+ * Returns a users collection from a yaml list of user email addresses in the field
+ *
+ * @param \Kirby\Cms\Field $field
+ * @param string $separator
+ * @return \Kirby\Cms\Users
+ */
+ 'toUsers' => function (Field $field, string $separator = 'yaml') use ($app) {
+ return $app->users()->find(false, false, ...$field->toData($separator));
+ },
+
+ // inspectors
+
+ /**
+ * Returns the length of the field content
+ */
+ 'length' => function (Field $field) {
+ return Str::length($field->value);
+ },
+
+ /**
+ * Returns the number of words in the text
+ */
+ 'words' => function (Field $field) {
+ return str_word_count(strip_tags($field->value));
+ },
+
+ // manipulators
+
+ /**
+ * Escapes the field value to be safely used in HTML
+ * templates without the risk of XSS attacks
+ *
+ * @param \Kirby\Cms\Field $field
+ * @param string $context html, attr, js or css
+ */
+ 'escape' => function (Field $field, string $context = 'html') {
+ $field->value = esc($field->value, $context);
+ return $field;
+ },
+
+ /**
+ * Creates an excerpt of the field value without html
+ * or any other formatting.
+ *
+ * @param \Kirby\Cms\Field $field
+ * @param int $cahrs
+ * @param bool $strip
+ * @param string $rep
+ * @return \Kirby\Cms\Field
+ */
+ 'excerpt' => function (Field $field, int $chars = 0, bool $strip = true, string $rep = '…') {
+ $field->value = Str::excerpt($field->kirbytext()->value(), $chars, $strip, $rep);
+ return $field;
+ },
+
+ /**
+ * Converts the field content to valid HTML
+ *
+ * @param \Kirby\Cms\Field $field
+ * @return \Kirby\Cms\Field
+ */
+ 'html' => function (Field $field) {
+ $field->value = htmlentities($field->value, ENT_COMPAT, 'utf-8');
+ return $field;
+ },
+
+ /**
+ * Converts all line breaks in the field content to ` Сигурни ли сте, че искате да зададете {name} за език по подразбиране? Действието не може да бъде отменено. В случай, че в {name} има непреведено съдържание, то части от сайта ви могат да останат празни. Segur que voleu convertir {name} a l'idioma predeterminat? Això no es pot desfer. Si {name} té contingut no traduït, ja no podreu tornar enrere i algunes parts del vostre lloc poden quedar buides. Opravdu chcete převést{name} na výchozí jazyk? Tuto volbu nelze vzít zpátky. Pokud {name} obsahuje nepřeložený text, nebude již k dispozici záložní varianta a části stránky mohou zůstat prázdné. Ønsker du virkelig at konvertere {name} til standardsproget? Dette kan ikke fortrydes. Hvis {name} har uoversat indhold, vil der ikke længere være et gyldigt tilbagefald og dele af dit website vil måske fremstå tomt. Willst du {name} wirklich in die Standardsprache umwandeln? Dieser Schritt kann nicht rückgängig gemacht werden. Wenn {name} unübersetzte Felder hat, gibt es keine gültigen Standardwerte für diese Felder und Inhalte könnten verloren gehen. Θέλετε πραγματικά να μετατρέψετε τη {name} στην προεπιλεγμένη γλώσσα; Αυτό δεν μπορεί να ανακληθεί. Αν το {name} χει μη μεταφρασμένο περιεχόμενο, δεν θα υπάρχει πλέον έγκυρη εναλλακτική λύση και τμήματα του ιστότοπού σας ενδέχεται να είναι κενά. Do you really want to convert {name} to the default language? This cannot be undone. If {name} has untranslated content, there will no longer be a valid fallback and parts of your site might be empty. Realmente deseas convertir {name} al idioma por defecto? Esta acción no se puede deshacer. Si {name} tiene contenido sin traducir, no habrá vuelta atras y tu sitio puede quedar con partes sin contenido. ",
+ "language.deleted": "El idioma ha sido borrado",
+ "language.direction": "Dirección de lectura",
+ "language.direction.ltr": "De Izquierda a derecha",
+ "language.direction.rtl": "De derecha a izquierda",
+ "language.locale": "Cadena de localización PHP",
+ "language.locale.warning": "Estas utilizando un configuración local. Por favor modifícalo en el archivo del lenguaje en /site/languages",
+ "language.name": "Nombre",
+ "language.updated": "El idioma a sido actualizado",
+
+ "languages": "Idiomas",
+ "languages.default": "Idioma por defecto",
+ "languages.empty": "Todavía no hay idiomas",
+ "languages.secondary": "Idiomas secundarios",
+ "languages.secondary.empty": "Todavía no hay idiomas secundarios",
+
+ "license": "Licencia",
+ "license.buy": "Comprar una licencia",
+ "license.register": "Registrar",
+ "license.register.help":
+ "Recibió su código de licencia después de la compra por correo electrónico. Por favor copie y pegue para registrarse.",
+ "license.register.label": "Por favor, ingresa tu código de licencia",
+ "license.register.success": "Gracias por apoyar a Kirby",
+ "license.unregistered": "Este es un demo no registrado de Kirby",
+
+ "link": "Enlace",
+ "link.text": "Texto de Enlace",
+
+ "loading": "Cargando",
+
+ "lock.unsaved": "Cambios sin guardar",
+ "lock.unsaved.empty": "No hay más cambios sin guardar",
+ "lock.isLocked": "Cambios sin guardar por {email}",
+ "lock.file.isLocked": "El archivo está siendo actualmente editado por {email} y no puede ser cambiado.",
+ "lock.page.isLocked": "La página está siendo actualmente editada por {email} y no puede ser cambiada.",
+ "lock.unlock": "Desbloquear",
+ "lock.isUnlocked": "Tus cambios sin guardar han sido sobrescritos por otro usuario. Puedes descargar los cambios y fusionarlos manualmente.",
+
+ "login": "Iniciar sesi\u00f3n",
+ "login.remember": "Mantener mi sesión iniciada",
+
+ "logout": "Cerrar sesi\u00f3n",
+
+ "menu": "Menù",
+ "meridiem": "AM/PM",
+ "mime": "Tipos de medios",
+ "minutes": "Minutos",
+
+ "month": "Mes",
+ "months.april": "Abril",
+ "months.august": "Agosto",
+ "months.december": "Diciembre",
+ "months.february": "Febrero",
+ "months.january": "Enero",
+ "months.july": "Julio",
+ "months.june": "Junio",
+ "months.march": "Marzo",
+ "months.may": "Mayo",
+ "months.november": "Noviembre",
+ "months.october": "Octubre",
+ "months.september": "Septiembre",
+
+ "more": "Màs",
+ "name": "Nombre",
+ "next": "Siguiente",
+ "off": "Apagado",
+ "on": "Encendido",
+ "open": "Abrir",
+ "options": "Opciones",
+
+ "orientation": "Orientación",
+ "orientation.landscape": "Paisaje",
+ "orientation.portrait": "Retrato",
+ "orientation.square": "Diapositiva",
+
+ "page.changeSlug": "Cambiar URL",
+ "page.changeSlug.fromTitle": "Crear a partir del t\u00edtulo",
+ "page.changeStatus": "Cambiar estado",
+ "page.changeStatus.position": "Por favor selecciona una posición",
+ "page.changeStatus.select": "Selecciona un nuevo estado",
+ "page.changeTemplate": "Cambiar plantilla",
+ "page.delete.confirm":
+ "¿Estás seguro que deseas eliminar {title}?",
+ "page.delete.confirm.subpages":
+ "Esta página tiene subpáginas. {name}
` tags.
+ * @since 3.3.0
+ *
+ * @param \Kirby\Cms\Field $field
+ * @return \Kirby\Cms\Field
+ */
+ 'nl2br' => function (Field $field) {
+ $field->value = nl2br($field->value, false);
+ return $field;
+ },
+
+ /**
+ * Converts the field content from Markdown/Kirbytext to valid HTML
+ *
+ * @param \Kirby\Cms\Field $field
+ * @return \Kirby\Cms\Field
+ */
+ 'kirbytext' => function (Field $field) use ($app) {
+ $field->value = $app->kirbytext($field->value, [
+ 'parent' => $field->parent(),
+ 'field' => $field
+ ]);
+
+ return $field;
+ },
+
+ /**
+ * Converts the field content from inline Markdown/Kirbytext
+ * to valid HTML
+ * @since 3.1.0
+ *
+ * @param \Kirby\Cms\Field $field
+ * @return \Kirby\Cms\Field
+ */
+ 'kirbytextinline' => function (Field $field) use ($app) {
+ $field->value = $app->kirbytext($field->value, [
+ 'parent' => $field->parent(),
+ 'field' => $field
+ ], true);
+
+ return $field;
+ },
+
+ /**
+ * Parses all KirbyTags without also parsing Markdown
+ *
+ * @param \Kirby\Cms\Field $field
+ * @return \Kirby\Cms\Field
+ */
+ 'kirbytags' => function (Field $field) use ($app) {
+ $field->value = $app->kirbytags($field->value, [
+ 'parent' => $field->parent(),
+ 'field' => $field
+ ]);
+
+ return $field;
+ },
+
+ /**
+ * Strips all block-level HTML elements from the field value,
+ * it can be safely placed inside of other inline elements
+ * without the risk of breaking the HTML structure.
+ * @since 3.3.0
+ *
+ * @param \Kirby\Cms\Field $field
+ * @return \Kirby\Cms\Field
+ */
+ 'inline' => function (Field $field) {
+ // List of valid inline elements taken from: https://developer.mozilla.org/de/docs/Web/HTML/Inline_elemente
+ // Obsolete elements, script tags, image maps and form elements have
+ // been excluded for safety reasons and as they are most likely not
+ // needed in most cases.
+ $field->value = strip_tags($field->value, '');
+ return $field;
+ },
+
+ /**
+ * Converts the field content to lowercase
+ *
+ * @param \Kirby\Cms\Field $field
+ * @return \Kirby\Cms\Field
+ */
+ 'lower' => function (Field $field) {
+ $field->value = Str::lower($field->value);
+ return $field;
+ },
+
+ /**
+ * Converts markdown to valid HTML
+ *
+ * @param \Kirby\Cms\Field $field
+ * @return \Kirby\Cms\Field
+ */
+ 'markdown' => function (Field $field) use ($app) {
+ $field->value = $app->markdown($field->value);
+ return $field;
+ },
+
+ /**
+ * Converts the field content to valid XML
+ *
+ * @param \Kirby\Cms\Field $field
+ * @return \Kirby\Cms\Field
+ */
+ 'xml' => function (Field $field) {
+ $field->value = Xml::encode($field->value);
+ return $field;
+ },
+
+ /**
+ * Cuts the string after the given length and
+ * adds "…" if it is longer
+ *
+ * @param \Kirby\Cms\Field $field
+ * @param int $length The number of characters in the string
+ * @param string $appendix An optional replacement for the missing rest
+ * @return \Kirby\Cms\Field
+ */
+ 'short' => function (Field $field, int $length, string $appendix = '…') {
+ $field->value = Str::short($field->value, $length, $appendix);
+ return $field;
+ },
+
+ /**
+ * Converts the field content to a slug
+ *
+ * @param \Kirby\Cms\Field $field
+ * @return \Kirby\cms\Field
+ */
+ 'slug' => function (Field $field) {
+ $field->value = Str::slug($field->value);
+ return $field;
+ },
+
+ /**
+ * Applies SmartyPants to the field
+ *
+ * @param \Kirby\Cms\Field $field
+ * @return \Kirby\cms\Field
+ */
+ 'smartypants' => function (Field $field) use ($app) {
+ $field->value = $app->smartypants($field->value);
+ return $field;
+ },
+
+ /**
+ * Splits the field content into an array
+ *
+ * @param \Kirby\Cms\Field $field
+ * @return \Kirby\cms\Field
+ */
+ 'split' => function (Field $field, $separator = ',') {
+ return Str::split((string)$field->value, $separator);
+ },
+
+ /**
+ * Converts the field content to uppercase
+ *
+ * @param \Kirby\Cms\Field $field
+ * @return \Kirby\cms\Field
+ */
+ 'upper' => function (Field $field) {
+ $field->value = Str::upper($field->value);
+ return $field;
+ },
+
+ /**
+ * Avoids typographical widows in strings by replacing
+ * the last space with ` `
+ *
+ * @param \Kirby\Cms\Field $field
+ * @return \Kirby\cms\Field
+ */
+ 'widont' => function (Field $field) {
+ $field->value = Str::widont($field->value);
+ return $field;
+ },
+
+ // aliases
+
+ /**
+ * Parses yaml in the field content and returns an array
+ *
+ * @param \Kirby\Cms\Field $field
+ * @return array
+ */
+ 'yaml' => function (Field $field): array {
+ return $field->toData('yaml');
+ },
+
+ ];
+};
diff --git a/kirby/config/presets/files.php b/kirby/config/presets/files.php
new file mode 100755
index 0000000..fe0a7e5
--- /dev/null
+++ b/kirby/config/presets/files.php
@@ -0,0 +1,24 @@
+ [
+ 'headline' => $props['headline'] ?? t('files'),
+ 'type' => 'files',
+ 'layout' => $props['layout'] ?? 'cards',
+ 'template' => $props['template'] ?? null,
+ 'image' => $props['image'] ?? null,
+ 'info' => '{{ file.dimensions }}'
+ ]
+ ];
+
+ // remove global options
+ unset(
+ $props['headline'],
+ $props['layout'],
+ $props['template'],
+ $props['image']
+ );
+
+ return $props;
+};
diff --git a/kirby/config/presets/page.php b/kirby/config/presets/page.php
new file mode 100755
index 0000000..62df5de
--- /dev/null
+++ b/kirby/config/presets/page.php
@@ -0,0 +1,72 @@
+ $props
+ ];
+ }
+
+ return array_replace_recursive($defaults, $props);
+ };
+
+ if (empty($props['sidebar']) === false) {
+ $sidebar = $props['sidebar'];
+ } else {
+ $sidebar = [];
+
+ $pages = $props['pages'] ?? [];
+ $files = $props['files'] ?? [];
+
+ if ($pages !== false) {
+ $sidebar['pages'] = $section([
+ 'headline' => t('pages'),
+ 'type' => 'pages',
+ 'status' => 'all',
+ 'layout' => 'list',
+ ], $pages);
+ }
+
+ if ($files !== false) {
+ $sidebar['files'] = $section([
+ 'headline' => t('files'),
+ 'type' => 'files',
+ 'layout' => 'list'
+ ], $files);
+ }
+ }
+
+ if (empty($sidebar) === true) {
+ $props['fields'] = $props['fields'] ?? [];
+
+ unset(
+ $props['files'],
+ $props['pages']
+ );
+ } else {
+ $props['columns'] = [
+ [
+ 'width' => '2/3',
+ 'fields' => $props['fields'] ?? []
+ ],
+ [
+ 'width' => '1/3',
+ 'sections' => $sidebar
+ ],
+ ];
+
+ unset(
+ $props['fields'],
+ $props['files'],
+ $props['pages'],
+ $props['sidebar']
+ );
+ }
+
+ return $props;
+};
diff --git a/kirby/config/presets/pages.php b/kirby/config/presets/pages.php
new file mode 100755
index 0000000..5bba76b
--- /dev/null
+++ b/kirby/config/presets/pages.php
@@ -0,0 +1,57 @@
+ $headline,
+ 'type' => 'pages',
+ 'layout' => 'list',
+ 'status' => $status
+ ];
+
+ if ($props === true) {
+ $props = [];
+ }
+
+ if (is_string($props) === true) {
+ $props = [
+ 'headline' => $props
+ ];
+ }
+
+ // inject the global templates definition
+ if (empty($templates) === false) {
+ $props['templates'] = $props['templates'] ?? $templates;
+ }
+
+ return array_replace_recursive($defaults, $props);
+ };
+
+ $sections = [];
+
+ $drafts = $props['drafts'] ?? [];
+ $unlisted = $props['unlisted'] ?? false;
+ $listed = $props['listed'] ?? [];
+
+
+ if ($drafts !== false) {
+ $sections['drafts'] = $section(t('pages.status.draft'), 'drafts', $drafts);
+ }
+
+ if ($unlisted !== false) {
+ $sections['unlisted'] = $section(t('pages.status.unlisted'), 'unlisted', $unlisted);
+ }
+
+ if ($listed !== false) {
+ $sections['listed'] = $section(t('pages.status.listed'), 'listed', $listed);
+ }
+
+ // cleaning up
+ unset($props['drafts'], $props['unlisted'], $props['listed'], $props['templates']);
+
+ return array_merge($props, ['sections' => $sections]);
+};
diff --git a/kirby/config/roots.php b/kirby/config/roots.php
new file mode 100755
index 0000000..c473763
--- /dev/null
+++ b/kirby/config/roots.php
@@ -0,0 +1,90 @@
+ function (array $roots) {
+ return realpath(__DIR__ . '/../');
+ },
+
+ // i18n
+ 'i18n' => function (array $roots) {
+ return $roots['kirby'] . '/i18n';
+ },
+ 'i18n:translations' => function (array $roots) {
+ return $roots['i18n'] . '/translations';
+ },
+ 'i18n:rules' => function (array $roots) {
+ return $roots['i18n'] . '/rules';
+ },
+
+ // index
+ 'index' => function (array $roots) {
+ return realpath(__DIR__ . '/../../');
+ },
+
+ // assets
+ 'assets' => function (array $roots) {
+ return $roots['index'] . '/assets';
+ },
+
+ // content
+ 'content' => function (array $roots) {
+ return $roots['index'] . '/content';
+ },
+
+ // media
+ 'media' => function (array $roots) {
+ return $roots['index'] . '/media';
+ },
+
+ // panel
+ 'panel' => function (array $roots) {
+ return $roots['kirby'] . '/panel';
+ },
+
+ // site
+ 'site' => function (array $roots) {
+ return $roots['index'] . '/site';
+ },
+ 'accounts' => function (array $roots) {
+ return $roots['site'] . '/accounts';
+ },
+ 'blueprints' => function (array $roots) {
+ return $roots['site'] . '/blueprints';
+ },
+ 'cache' => function (array $roots) {
+ return $roots['site'] . '/cache';
+ },
+ 'collections' => function (array $roots) {
+ return $roots['site'] . '/collections';
+ },
+ 'config' => function (array $roots) {
+ return $roots['site'] . '/config';
+ },
+ 'controllers' => function (array $roots) {
+ return $roots['site'] . '/controllers';
+ },
+ 'languages' => function (array $roots) {
+ return $roots['site'] . '/languages';
+ },
+ 'models' => function (array $roots) {
+ return $roots['site'] . '/models';
+ },
+ 'plugins' => function (array $roots) {
+ return $roots['site'] . '/plugins';
+ },
+ 'sessions' => function (array $roots) {
+ return $roots['site'] . '/sessions';
+ },
+ 'snippets' => function (array $roots) {
+ return $roots['site'] . '/snippets';
+ },
+ 'templates' => function (array $roots) {
+ return $roots['site'] . '/templates';
+ },
+
+ // blueprints
+ 'roles' => function (array $roots) {
+ return $roots['blueprints'] . '/users';
+ },
+];
diff --git a/kirby/config/routes.php b/kirby/config/routes.php
new file mode 100755
index 0000000..6c4a02b
--- /dev/null
+++ b/kirby/config/routes.php
@@ -0,0 +1,151 @@
+option('api.slug', 'api');
+ $panel = $kirby->option('panel.slug', 'panel');
+ $index = $kirby->url('index');
+ $media = $kirby->url('media');
+
+ if (Str::startsWith($media, $index) === true) {
+ $media = Str::after($media, $index);
+ } else {
+ // media URL is outside of the site, we can't make routing work;
+ // fall back to the standard media route
+ $media = 'media';
+ }
+
+ /**
+ * Before routes are running before the
+ * plugin routes and cannot be overwritten by
+ * plugins.
+ */
+ $before = [
+ [
+ 'pattern' => $api . '/(:all)',
+ 'method' => 'ALL',
+ 'env' => 'api',
+ 'action' => function ($path = null) use ($kirby) {
+ if ($kirby->option('api') === false) {
+ return null;
+ }
+
+ $request = $kirby->request();
+
+ return $kirby->api()->render($path, $this->method(), [
+ 'body' => $request->body()->toArray(),
+ 'files' => $request->files()->toArray(),
+ 'headers' => $request->headers(),
+ 'query' => $request->query()->toArray(),
+ ]);
+ }
+ ],
+ [
+ 'pattern' => $media . '/plugins/index.(css|js)',
+ 'env' => 'media',
+ 'action' => function (string $type) use ($kirby) {
+ $plugins = new PanelPlugins();
+
+ return $kirby
+ ->response()
+ ->type($type)
+ ->body($plugins->read($type));
+ }
+ ],
+ [
+ 'pattern' => $media . '/plugins/(:any)/(:any)/(:all).(css|gif|js|jpg|png|svg|webp|woff2|woff)',
+ 'env' => 'media',
+ 'action' => function (string $provider, string $pluginName, string $filename, string $extension) {
+ return PluginAssets::resolve($provider . '/' . $pluginName, $filename . '.' . $extension);
+ }
+ ],
+ [
+ 'pattern' => $panel . '/(:all?)',
+ 'env' => 'panel',
+ 'action' => function () use ($kirby) {
+ if ($kirby->option('panel') === false) {
+ return null;
+ }
+
+ return Panel::render($kirby);
+ }
+ ],
+ [
+ 'pattern' => $media . '/pages/(:all)/(:any)/(:any)',
+ 'env' => 'media',
+ 'action' => function ($path, $hash, $filename) use ($kirby) {
+ return Media::link($kirby->page($path), $hash, $filename);
+ }
+ ],
+ [
+ 'pattern' => $media . '/site/(:any)/(:any)',
+ 'env' => 'media',
+ 'action' => function ($hash, $filename) use ($kirby) {
+ return Media::link($kirby->site(), $hash, $filename);
+ }
+ ],
+ [
+ 'pattern' => $media . '/users/(:any)/(:any)/(:any)',
+ 'env' => 'media',
+ 'action' => function ($id, $hash, $filename) use ($kirby) {
+ return Media::link($kirby->user($id), $hash, $filename);
+ }
+ ],
+ [
+ 'pattern' => $media . '/assets/(:all)/(:any)/(:any)',
+ 'env' => 'media',
+ 'action' => function ($path, $hash, $filename) {
+ return Media::thumb($path, $hash, $filename);
+ }
+ ]
+ ];
+
+ // Multi-language setup
+ if ($kirby->multilang() === true) {
+ $after = LanguageRoutes::create($kirby);
+ } else {
+
+ // Single-language home
+ $after[] = [
+ 'pattern' => '',
+ 'method' => 'ALL',
+ 'env' => 'site',
+ 'action' => function () use ($kirby) {
+ return $kirby->resolve();
+ }
+ ];
+
+ // redirect the home page folder to the real homepage
+ $after[] = [
+ 'pattern' => $kirby->option('home', 'home'),
+ 'method' => 'ALL',
+ 'env' => 'site',
+ 'action' => function () use ($kirby) {
+ return $kirby
+ ->response()
+ ->redirect($kirby->site()->url());
+ }
+ ];
+
+ // Single-language subpages
+ $after[] = [
+ 'pattern' => '(:all)',
+ 'method' => 'ALL',
+ 'env' => 'site',
+ 'action' => function (string $path) use ($kirby) {
+ return $kirby->resolve($path);
+ }
+ ];
+ }
+
+ return [
+ 'before' => $before,
+ 'after' => $after
+ ];
+};
diff --git a/kirby/config/sections/fields.php b/kirby/config/sections/fields.php
new file mode 100755
index 0000000..84c9276
--- /dev/null
+++ b/kirby/config/sections/fields.php
@@ -0,0 +1,66 @@
+ [
+ 'fields' => function (array $fields = []) {
+ return $fields;
+ }
+ ],
+ 'computed' => [
+ 'form' => function () {
+ $fields = $this->fields;
+ $disabled = $this->model->permissions()->update() === false;
+ $content = $this->model->content()->toArray();
+
+ if ($disabled === true) {
+ foreach ($fields as $key => $props) {
+ $fields[$key]['disabled'] = true;
+ }
+ }
+
+ return new Form([
+ 'fields' => $fields,
+ 'values' => $content,
+ 'model' => $this->model,
+ 'strict' => true
+ ]);
+ },
+ 'fields' => function () {
+ $fields = $this->form->fields()->toArray();
+
+ if (is_a($this->model, 'Kirby\Cms\Page') === true || is_a($this->model, 'Kirby\Cms\Site') === true) {
+ // the title should never be updated directly via
+ // fields section to avoid conflicts with the rename dialog
+ unset($fields['title']);
+ }
+
+ foreach ($fields as $index => $props) {
+ unset($fields[$index]['value']);
+ }
+
+ return $fields;
+ },
+ 'errors' => function () {
+ return $this->form->errors();
+ },
+ 'data' => function () {
+ $values = $this->form->values();
+
+ if (is_a($this->model, 'Kirby\Cms\Page') === true || is_a($this->model, 'Kirby\Cms\Site') === true) {
+ // the title should never be updated directly via
+ // fields section to avoid conflicts with the rename dialog
+ unset($values['title']);
+ }
+
+ return $values;
+ }
+ ],
+ 'toArray' => function () {
+ return [
+ 'errors' => $this->errors,
+ 'fields' => $this->fields,
+ ];
+ }
+];
diff --git a/kirby/config/sections/files.php b/kirby/config/sections/files.php
new file mode 100755
index 0000000..1d15ca8
--- /dev/null
+++ b/kirby/config/sections/files.php
@@ -0,0 +1,236 @@
+ [
+ 'empty',
+ 'headline',
+ 'help',
+ 'layout',
+ 'min',
+ 'max',
+ 'pagination',
+ 'parent',
+ ],
+ 'props' => [
+ /**
+ * Enables/disables reverse sorting
+ */
+ 'flip' => function (bool $flip = false) {
+ return $flip;
+ },
+ /**
+ * Image options to control the source and look of file previews
+ */
+ 'image' => function ($image = null) {
+ return $image ?? [];
+ },
+ /**
+ * Optional info text setup. Info text is shown on the right (lists) or below (cards) the filename.
+ */
+ 'info' => function (string $info = null) {
+ return $info;
+ },
+ /**
+ * The size option controls the size of cards. By default cards are auto-sized and the cards grid will always fill the full width. With a size you can disable auto-sizing. Available sizes: `tiny`, `small`, `medium`, `large`, `huge`
+ */
+ 'size' => function (string $size = 'auto') {
+ return $size;
+ },
+ /**
+ * Enables/disables manual sorting
+ */
+ 'sortable' => function (bool $sortable = true) {
+ return $sortable;
+ },
+ /**
+ * Overwrites manual sorting and sorts by the given field and sorting direction (i.e. `filename desc`)
+ */
+ 'sortBy' => function (string $sortBy = null) {
+ return $sortBy;
+ },
+ /**
+ * Filters all files by template and also sets the template, which will be used for all uploads
+ */
+ 'template' => function (string $template = null) {
+ return $template;
+ },
+ /**
+ * Setup for the main text in the list or cards. By default this will display the filename.
+ */
+ 'text' => function (string $text = '{{ file.filename }}') {
+ return $text;
+ }
+ ],
+ 'computed' => [
+ 'accept' => function () {
+ if ($this->template) {
+ $file = new File([
+ 'filename' => 'tmp',
+ 'template' => $this->template
+ ]);
+
+ return $file->blueprint()->accept()['mime'] ?? '*';
+ }
+
+ return null;
+ },
+ 'parent' => function () {
+ return $this->parentModel();
+ },
+ 'files' => function () {
+ $files = $this->parent->files()->template($this->template);
+
+ if ($this->sortBy) {
+ $files = $files->sortBy(...$files::sortArgs($this->sortBy));
+ } elseif ($this->sortable === true) {
+ $files = $files->sortBy('sort', 'asc', 'filename', 'asc');
+ }
+
+ // flip
+ if ($this->flip === true) {
+ $files = $files->flip();
+ }
+
+ // apply the default pagination
+ $files = $files->paginate([
+ 'page' => $this->page,
+ 'limit' => $this->limit
+ ]);
+
+ return $files;
+ },
+ 'data' => function () {
+ $data = [];
+
+ // the drag text needs to be absolute when the files come from
+ // a different parent model
+ $dragTextAbsolute = $this->model->is($this->parent) === false;
+
+ foreach ($this->files as $file) {
+ $image = $file->panelImage($this->image);
+
+ $data[] = [
+ 'dragText' => $file->dragText('auto', $dragTextAbsolute),
+ 'extension' => $file->extension(),
+ 'filename' => $file->filename(),
+ 'id' => $file->id(),
+ 'icon' => $file->panelIcon($image),
+ 'image' => $image,
+ 'info' => $file->toString($this->info ?? false),
+ 'link' => $file->panelUrl(true),
+ 'mime' => $file->mime(),
+ 'parent' => $file->parent()->panelPath(),
+ 'text' => $file->toString($this->text),
+ 'url' => $file->url(),
+ ];
+ }
+
+ return $data;
+ },
+ 'total' => function () {
+ return $this->files->pagination()->total();
+ },
+ 'errors' => function () {
+ $errors = [];
+
+ if ($this->validateMax() === false) {
+ $errors['max'] = I18n::template('error.section.files.max.' . I18n::form($this->max), [
+ 'max' => $this->max,
+ 'section' => $this->headline
+ ]);
+ }
+
+ if ($this->validateMin() === false) {
+ $errors['min'] = I18n::template('error.section.files.min.' . I18n::form($this->min), [
+ 'min' => $this->min,
+ 'section' => $this->headline
+ ]);
+ }
+
+ if (empty($errors) === true) {
+ return [];
+ }
+
+ return [
+ $this->name => [
+ 'label' => $this->headline,
+ 'message' => $errors,
+ ]
+ ];
+ },
+ 'link' => function () {
+ $modelLink = $this->model->panelUrl(true);
+ $parentLink = $this->parent->panelUrl(true);
+
+ if ($modelLink !== $parentLink) {
+ return $parentLink;
+ }
+ },
+ 'pagination' => function () {
+ return $this->pagination();
+ },
+ 'sortable' => function () {
+ if ($this->sortable === false) {
+ return false;
+ }
+
+ if ($this->sortBy !== null) {
+ return false;
+ }
+
+ if ($this->flip === true) {
+ return false;
+ }
+
+ return true;
+ },
+ 'upload' => function () {
+ if ($this->isFull() === true) {
+ return false;
+ }
+
+ // count all uploaded files
+ $total = count($this->data);
+ $max = $this->max ? $this->max - $total : null;
+
+ if ($this->max && $total === $this->max - 1) {
+ $multiple = false;
+ } else {
+ $multiple = true;
+ }
+
+ return [
+ 'accept' => $this->accept,
+ 'multiple' => $multiple,
+ 'max' => $max,
+ 'api' => $this->parent->apiUrl(true) . '/files',
+ 'attributes' => array_filter([
+ 'template' => $this->template
+ ])
+ ];
+ }
+ ],
+ 'toArray' => function () {
+ return [
+ 'data' => $this->data,
+ 'errors' => $this->errors,
+ 'options' => [
+ 'accept' => $this->accept,
+ 'empty' => $this->empty,
+ 'headline' => $this->headline,
+ 'help' => $this->help,
+ 'layout' => $this->layout,
+ 'link' => $this->link,
+ 'max' => $this->max,
+ 'min' => $this->min,
+ 'size' => $this->size,
+ 'sortable' => $this->sortable,
+ 'upload' => $this->upload
+ ],
+ 'pagination' => $this->pagination
+ ];
+ }
+];
diff --git a/kirby/config/sections/info.php b/kirby/config/sections/info.php
new file mode 100755
index 0000000..254a76b
--- /dev/null
+++ b/kirby/config/sections/info.php
@@ -0,0 +1,35 @@
+ [
+ 'headline',
+ ],
+ 'props' => [
+ 'text' => function ($text = null) {
+ return I18n::translate($text, $text);
+ },
+ 'theme' => function (string $theme = null) {
+ return $theme;
+ }
+ ],
+ 'computed' => [
+ 'text' => function () {
+ if ($this->text) {
+ $text = $this->model()->toString($this->text);
+ $text = $this->kirby()->kirbytext($text);
+ return $text;
+ }
+ },
+ ],
+ 'toArray' => function () {
+ return [
+ 'options' => [
+ 'headline' => $this->headline,
+ 'text' => $this->text,
+ 'theme' => $this->theme
+ ]
+ ];
+ }
+];
diff --git a/kirby/config/sections/mixins/empty.php b/kirby/config/sections/mixins/empty.php
new file mode 100755
index 0000000..1c58194
--- /dev/null
+++ b/kirby/config/sections/mixins/empty.php
@@ -0,0 +1,21 @@
+ [
+ /**
+ * Sets the text for the empty state box
+ */
+ 'empty' => function ($empty = null) {
+ return I18n::translate($empty, $empty);
+ }
+ ],
+ 'computed' => [
+ 'empty' => function () {
+ if ($this->empty) {
+ return $this->model()->toString($this->empty);
+ }
+ }
+ ]
+];
diff --git a/kirby/config/sections/mixins/headline.php b/kirby/config/sections/mixins/headline.php
new file mode 100755
index 0000000..f4bb7e1
--- /dev/null
+++ b/kirby/config/sections/mixins/headline.php
@@ -0,0 +1,23 @@
+ [
+ /**
+ * The headline for the section. This can be a simple string or a template with additional info from the parent page.
+ */
+ 'headline' => function ($headline = null) {
+ return I18n::translate($headline, $headline);
+ }
+ ],
+ 'computed' => [
+ 'headline' => function () {
+ if ($this->headline) {
+ return $this->model()->toString($this->headline);
+ }
+
+ return ucfirst($this->name);
+ }
+ ]
+];
diff --git a/kirby/config/sections/mixins/help.php b/kirby/config/sections/mixins/help.php
new file mode 100755
index 0000000..80f42ee
--- /dev/null
+++ b/kirby/config/sections/mixins/help.php
@@ -0,0 +1,23 @@
+ [
+ /**
+ * Sets the help text
+ */
+ 'help' => function ($help = null) {
+ return I18n::translate($help, $help);
+ }
+ ],
+ 'computed' => [
+ 'help' => function () {
+ if ($this->help) {
+ $help = $this->model()->toString($this->help);
+ $help = $this->kirby()->kirbytext($help);
+ return $help;
+ }
+ }
+ ]
+];
diff --git a/kirby/config/sections/mixins/layout.php b/kirby/config/sections/mixins/layout.php
new file mode 100755
index 0000000..4a7a621
--- /dev/null
+++ b/kirby/config/sections/mixins/layout.php
@@ -0,0 +1,12 @@
+ [
+ /**
+ * Section layout. Available layout methods: `list`, `cards`.
+ */
+ 'layout' => function (string $layout = 'list') {
+ return $layout === 'cards' ? 'cards' : 'list';
+ }
+ ]
+];
diff --git a/kirby/config/sections/mixins/max.php b/kirby/config/sections/mixins/max.php
new file mode 100755
index 0000000..5ce303c
--- /dev/null
+++ b/kirby/config/sections/mixins/max.php
@@ -0,0 +1,28 @@
+ [
+ /**
+ * Sets the maximum number of allowed entries in the section
+ */
+ 'max' => function (int $max = null) {
+ return $max;
+ }
+ ],
+ 'methods' => [
+ 'isFull' => function () {
+ if ($this->max) {
+ return $this->total >= $this->max;
+ }
+
+ return false;
+ },
+ 'validateMax' => function () {
+ if ($this->max && $this->total > $this->max) {
+ return false;
+ }
+
+ return true;
+ }
+ ]
+];
diff --git a/kirby/config/sections/mixins/min.php b/kirby/config/sections/mixins/min.php
new file mode 100755
index 0000000..bfc495d
--- /dev/null
+++ b/kirby/config/sections/mixins/min.php
@@ -0,0 +1,21 @@
+ [
+ /**
+ * Sets the minimum number of required entries in the section
+ */
+ 'min' => function (int $min = null) {
+ return $min;
+ }
+ ],
+ 'methods' => [
+ 'validateMin' => function () {
+ if ($this->min && $this->min > $this->total) {
+ return false;
+ }
+
+ return true;
+ }
+ ]
+];
diff --git a/kirby/config/sections/mixins/pagination.php b/kirby/config/sections/mixins/pagination.php
new file mode 100755
index 0000000..8bf3dee
--- /dev/null
+++ b/kirby/config/sections/mixins/pagination.php
@@ -0,0 +1,36 @@
+ [
+ /**
+ * Sets the number of items per page. If there are more items the pagination navigation will be shown at the bottom of the section.
+ */
+ 'limit' => function (int $limit = 20) {
+ return $limit;
+ },
+ /**
+ * Sets the default page for the pagination. This will overwrite default pagination.
+ */
+ 'page' => function (int $page = null) {
+ return get('page', $page);
+ },
+ ],
+ 'methods' => [
+ 'pagination' => function () {
+ $pagination = new Pagination([
+ 'limit' => $this->limit,
+ 'page' => $this->page,
+ 'total' => $this->total
+ ]);
+
+ return [
+ 'limit' => $pagination->limit(),
+ 'offset' => $pagination->offset(),
+ 'page' => $pagination->page(),
+ 'total' => $pagination->total(),
+ ];
+ },
+ ]
+];
diff --git a/kirby/config/sections/mixins/parent.php b/kirby/config/sections/mixins/parent.php
new file mode 100755
index 0000000..3534acf
--- /dev/null
+++ b/kirby/config/sections/mixins/parent.php
@@ -0,0 +1,43 @@
+ [
+ /**
+ * Sets the query to a parent to find items for the list
+ */
+ 'parent' => function (string $parent = null) {
+ return $parent;
+ }
+ ],
+ 'methods' => [
+ 'parentModel' => function () {
+ $parent = $this->parent;
+
+ if (is_string($parent) === true) {
+ $query = $parent;
+ $parent = $this->model->query($query);
+
+ if (!$parent) {
+ throw new Exception('The parent for the query "' . $query . '" cannot be found in the section "' . $this->name() . '"');
+ }
+
+ if (
+ is_a($parent, 'Kirby\Cms\Page') === false &&
+ is_a($parent, 'Kirby\Cms\Site') === false &&
+ is_a($parent, 'Kirby\Cms\File') === false &&
+ is_a($parent, 'Kirby\Cms\User') === false
+ ) {
+ throw new Exception('The parent for the section "' . $this->name() . '" has to be a page, site or user object');
+ }
+ }
+
+ if ($parent === null) {
+ return $this->model;
+ }
+
+ return $parent;
+ }
+ ]
+];
diff --git a/kirby/config/sections/pages.php b/kirby/config/sections/pages.php
new file mode 100755
index 0000000..9fd99e3
--- /dev/null
+++ b/kirby/config/sections/pages.php
@@ -0,0 +1,296 @@
+ [
+ 'empty',
+ 'headline',
+ 'help',
+ 'layout',
+ 'min',
+ 'max',
+ 'pagination',
+ 'parent'
+ ],
+ 'props' => [
+ /**
+ * Optional array of templates that should only be allowed to add.
+ */
+ 'create' => function ($add = null) {
+ return $add;
+ },
+ /**
+ * Enables/disables reverse sorting
+ */
+ 'flip' => function (bool $flip = false) {
+ return $flip;
+ },
+ /**
+ * Image options to control the source and look of page previews
+ */
+ 'image' => function ($image = null) {
+ return $image ?? [];
+ },
+ /**
+ * Optional info text setup. Info text is shown on the right (lists) or below (cards) the page title.
+ */
+ 'info' => function (string $info = null) {
+ return $info;
+ },
+ /**
+ * The size option controls the size of cards. By default cards are auto-sized and the cards grid will always fill the full width. With a size you can disable auto-sizing. Available sizes: `tiny`, `small`, `medium`, `large`, `huge`
+ */
+ 'size' => function (string $size = 'auto') {
+ return $size;
+ },
+ /**
+ * Enables/disables manual sorting
+ */
+ 'sortable' => function (bool $sortable = true) {
+ return $sortable;
+ },
+ /**
+ * Overwrites manual sorting and sorts by the given field and sorting direction (i.e. `date desc`)
+ */
+ 'sortBy' => function (string $sortBy = null) {
+ return $sortBy;
+ },
+ /**
+ * Filters pages by their status. Available status settings: `draft`, `unlisted`, `listed`, `published`, `all`.
+ */
+ 'status' => function (string $status = '') {
+ if ($status === 'drafts') {
+ $status = 'draft';
+ }
+
+ if (in_array($status, ['all', 'draft', 'published', 'listed', 'unlisted']) === false) {
+ $status = 'all';
+ }
+
+ return $status;
+ },
+ /**
+ * Filters the list by templates and sets template options when adding new pages to the section.
+ */
+ 'templates' => function ($templates = null) {
+ return A::wrap($templates ?? $this->template);
+ },
+ /**
+ * Setup for the main text in the list or cards. By default this will display the page title.
+ */
+ 'text' => function (string $text = '{{ page.title }}') {
+ return $text;
+ }
+ ],
+ 'computed' => [
+ 'parent' => function () {
+ return $this->parentModel();
+ },
+ 'pages' => function () {
+ switch ($this->status) {
+ case 'draft':
+ $pages = $this->parent->drafts();
+ break;
+ case 'listed':
+ $pages = $this->parent->children()->listed();
+ break;
+ case 'published':
+ $pages = $this->parent->children();
+ break;
+ case 'unlisted':
+ $pages = $this->parent->children()->unlisted();
+ break;
+ default:
+ $pages = $this->parent->childrenAndDrafts();
+ }
+
+ // loop for the best performance
+ foreach ($pages->data as $id => $page) {
+
+ // remove all protected pages
+ if ($page->isReadable() === false) {
+ unset($pages->data[$id]);
+ continue;
+ }
+
+ // filter by all set templates
+ if ($this->templates && in_array($page->intendedTemplate()->name(), $this->templates) === false) {
+ unset($pages->data[$id]);
+ continue;
+ }
+ }
+
+ // sort
+ if ($this->sortBy) {
+ $pages = $pages->sortBy(...$pages::sortArgs($this->sortBy));
+ }
+
+ // flip
+ if ($this->flip === true) {
+ $pages = $pages->flip();
+ }
+
+ // pagination
+ $pages = $pages->paginate([
+ 'page' => $this->page,
+ 'limit' => $this->limit
+ ]);
+
+ return $pages;
+ },
+ 'total' => function () {
+ return $this->pages->pagination()->total();
+ },
+ 'data' => function () {
+ $data = [];
+
+ foreach ($this->pages as $item) {
+ $permissions = $item->permissions();
+ $image = $item->panelImage($this->image);
+
+ $data[] = [
+ 'id' => $item->id(),
+ 'dragText' => $item->dragText(),
+ 'text' => $item->toString($this->text),
+ 'info' => $item->toString($this->info ?? false),
+ 'parent' => $item->parentId(),
+ 'icon' => $item->panelIcon($image),
+ 'image' => $image,
+ 'link' => $item->panelUrl(true),
+ 'status' => $item->status(),
+ 'permissions' => [
+ 'sort' => $permissions->can('sort'),
+ 'changeStatus' => $permissions->can('changeStatus')
+ ]
+ ];
+ }
+
+ return $data;
+ },
+ 'errors' => function () {
+ $errors = [];
+
+ if ($this->validateMax() === false) {
+ $errors['max'] = I18n::template('error.section.pages.max.' . I18n::form($this->max), [
+ 'max' => $this->max,
+ 'section' => $this->headline
+ ]);
+ }
+
+ if ($this->validateMin() === false) {
+ $errors['min'] = I18n::template('error.section.pages.min.' . I18n::form($this->min), [
+ 'min' => $this->min,
+ 'section' => $this->headline
+ ]);
+ }
+
+ if (empty($errors) === true) {
+ return [];
+ }
+
+ return [
+ $this->name => [
+ 'label' => $this->headline,
+ 'message' => $errors,
+ ]
+ ];
+ },
+ 'add' => function () {
+ if ($this->create === false) {
+ return false;
+ }
+
+ if (in_array($this->status, ['draft', 'all']) === false) {
+ return false;
+ }
+
+ if ($this->isFull() === true) {
+ return false;
+ }
+
+ return true;
+ },
+ 'link' => function () {
+ $modelLink = $this->model->panelUrl(true);
+ $parentLink = $this->parent->panelUrl(true);
+
+ if ($modelLink !== $parentLink) {
+ return $parentLink;
+ }
+ },
+ 'pagination' => function () {
+ return $this->pagination();
+ },
+ 'sortable' => function () {
+ if (in_array($this->status, ['listed', 'published', 'all']) === false) {
+ return false;
+ }
+
+ if ($this->sortable === false) {
+ return false;
+ }
+
+ if ($this->sortBy !== null) {
+ return false;
+ }
+
+ if ($this->flip === true) {
+ return false;
+ }
+
+ return true;
+ }
+ ],
+ 'methods' => [
+ 'blueprints' => function () {
+ $blueprints = [];
+ $templates = empty($this->create) === false ? A::wrap($this->create) : $this->templates;
+
+ if (empty($templates) === true) {
+ $templates = $this->kirby()->blueprints();
+ }
+
+ // convert every template to a usable option array
+ // for the template select box
+ foreach ($templates as $template) {
+ try {
+ $props = Blueprint::load('pages/' . $template);
+
+ $blueprints[] = [
+ 'name' => basename($props['name']),
+ 'title' => $props['title'],
+ ];
+ } catch (Throwable $e) {
+ $blueprints[] = [
+ 'name' => basename($template),
+ 'title' => ucfirst($template),
+ ];
+ }
+ }
+
+ return $blueprints;
+ }
+ ],
+ 'toArray' => function () {
+ return [
+ 'data' => $this->data,
+ 'errors' => $this->errors,
+ 'options' => [
+ 'add' => $this->add,
+ 'empty' => $this->empty,
+ 'headline' => $this->headline,
+ 'help' => $this->help,
+ 'layout' => $this->layout,
+ 'link' => $this->link,
+ 'max' => $this->max,
+ 'min' => $this->min,
+ 'size' => $this->size,
+ 'sortable' => $this->sortable
+ ],
+ 'pagination' => $this->pagination,
+ ];
+ }
+];
diff --git a/kirby/config/setup.php b/kirby/config/setup.php
new file mode 100755
index 0000000..acbc2df
--- /dev/null
+++ b/kirby/config/setup.php
@@ -0,0 +1,41 @@
+ [
+ 'attr' => [],
+ 'html' => function ($tag) {
+ return strtolower($tag->date) === 'year' ? date('Y') : date($tag->date);
+ }
+ ],
+
+ /**
+ * Email
+ */
+ 'email' => [
+ 'attr' => [
+ 'class',
+ 'rel',
+ 'target',
+ 'text',
+ 'title'
+ ],
+ 'html' => function ($tag) {
+ return Html::email($tag->value, $tag->text, [
+ 'class' => $tag->class,
+ 'rel' => $tag->rel,
+ 'target' => $tag->target,
+ 'title' => $tag->title,
+ ]);
+ }
+ ],
+
+ /**
+ * File
+ */
+ 'file' => [
+ 'attr' => [
+ 'class',
+ 'download',
+ 'rel',
+ 'target',
+ 'text',
+ 'title'
+ ],
+ 'html' => function ($tag) {
+ if (!$file = $tag->file($tag->value)) {
+ return $tag->text;
+ }
+
+ // use filename if the text is empty and make sure to
+ // ignore markdown italic underscores in filenames
+ if (empty($tag->text) === true) {
+ $tag->text = str_replace('_', '\_', $file->filename());
+ }
+
+ return Html::a($file->url(), $tag->text, [
+ 'class' => $tag->class,
+ 'download' => $tag->download !== 'false',
+ 'rel' => $tag->rel,
+ 'target' => $tag->target,
+ 'title' => $tag->title,
+ ]);
+ }
+ ],
+
+ /**
+ * Gist
+ */
+ 'gist' => [
+ 'attr' => [
+ 'file'
+ ],
+ 'html' => function ($tag) {
+ return Html::gist($tag->value, $tag->file);
+ }
+ ],
+
+ /**
+ * Image
+ */
+ 'image' => [
+ 'attr' => [
+ 'alt',
+ 'caption',
+ 'class',
+ 'height',
+ 'imgclass',
+ 'link',
+ 'linkclass',
+ 'rel',
+ 'target',
+ 'title',
+ 'width'
+ ],
+ 'html' => function ($tag) {
+ if ($tag->file = $tag->file($tag->value)) {
+ $tag->src = $tag->file->url();
+ $tag->alt = $tag->alt ?? $tag->file->alt()->or(' ')->value();
+ $tag->title = $tag->title ?? $tag->file->title()->value();
+ $tag->caption = $tag->caption ?? $tag->file->caption()->value();
+ } else {
+ $tag->src = Url::to($tag->value);
+ }
+
+ $link = function ($img) use ($tag) {
+ if (empty($tag->link) === true) {
+ return $img;
+ }
+
+ if ($link = $tag->file($tag->link)) {
+ $link = $link->url();
+ } else {
+ $link = $tag->link === 'self' ? $tag->src : $tag->link;
+ }
+
+ return Html::a($link, [$img], [
+ 'rel' => $tag->rel,
+ 'class' => $tag->linkclass,
+ 'target' => $tag->target
+ ]);
+ };
+
+ $image = Html::img($tag->src, [
+ 'width' => $tag->width,
+ 'height' => $tag->height,
+ 'class' => $tag->imgclass,
+ 'title' => $tag->title,
+ 'alt' => $tag->alt ?? ' '
+ ]);
+
+ if ($tag->kirby()->option('kirbytext.image.figure', true) === false) {
+ return $link($image);
+ }
+
+ // render KirbyText in caption
+ if ($tag->caption) {
+ $tag->caption = [$tag->kirby()->kirbytext($tag->caption, [], true)];
+ }
+
+ return Html::figure([ $link($image) ], $tag->caption, [
+ 'class' => $tag->class
+ ]);
+ }
+ ],
+
+ /**
+ * Link
+ */
+ 'link' => [
+ 'attr' => [
+ 'class',
+ 'lang',
+ 'rel',
+ 'role',
+ 'target',
+ 'title',
+ 'text',
+ ],
+ 'html' => function ($tag) {
+ if (empty($tag->lang) === false) {
+ $tag->value = Url::to($tag->value, $tag->lang);
+ }
+
+ return Html::a($tag->value, $tag->text, [
+ 'rel' => $tag->rel,
+ 'class' => $tag->class,
+ 'role' => $tag->role,
+ 'title' => $tag->title,
+ 'target' => $tag->target,
+ ]);
+ }
+ ],
+
+ /**
+ * Tel
+ */
+ 'tel' => [
+ 'attr' => [
+ 'class',
+ 'rel',
+ 'text',
+ 'title'
+ ],
+ 'html' => function ($tag) {
+ return Html::tel($tag->value, $tag->text, [
+ 'class' => $tag->class,
+ 'rel' => $tag->rel,
+ 'title' => $tag->title
+ ]);
+ }
+ ],
+
+ /**
+ * Twitter
+ */
+ 'twitter' => [
+ 'attr' => [
+ 'class',
+ 'rel',
+ 'target',
+ 'text',
+ 'title'
+ ],
+ 'html' => function ($tag) {
+
+ // get and sanitize the username
+ $username = str_replace('@', '', $tag->value);
+
+ // build the profile url
+ $url = 'https://twitter.com/' . $username;
+
+ // sanitize the link text
+ $text = $tag->text ?? '@' . $username;
+
+ // build the final link
+ return Html::a($url, $text, [
+ 'class' => $tag->class,
+ 'rel' => $tag->rel,
+ 'target' => $tag->target,
+ 'title' => $tag->title,
+ ]);
+ }
+ ],
+
+ /**
+ * Video
+ */
+ 'video' => [
+ 'attr' => [
+ 'class',
+ 'caption',
+ 'height',
+ 'width'
+ ],
+ 'html' => function ($tag) {
+ $video = Html::video(
+ $tag->value,
+ $tag->kirby()->option('kirbytext.video.options', []),
+ [
+ 'height' => $tag->height ?? $tag->kirby()->option('kirbytext.video.height'),
+ 'width' => $tag->width ?? $tag->kirby()->option('kirbytext.video.width'),
+ ]
+ );
+
+ return Html::figure([$video], $tag->caption, [
+ 'class' => $tag->class ?? $tag->kirby()->option('kirbytext.video.class', 'video'),
+ ]);
+ }
+ ],
+
+];
diff --git a/kirby/config/urls.php b/kirby/config/urls.php
new file mode 100755
index 0000000..1bfe0fd
--- /dev/null
+++ b/kirby/config/urls.php
@@ -0,0 +1,33 @@
+ function () {
+ return Url::index();
+ },
+ 'base' => function (array $urls) {
+ return rtrim($urls['index'], '/');
+ },
+ 'current' => function (array $urls) {
+ $path = trim($this->path(), '/');
+
+ if (empty($path) === true) {
+ return $urls['index'];
+ } else {
+ return $urls['base'] . '/' . $path;
+ }
+ },
+ 'assets' => function (array $urls) {
+ return $urls['base'] . '/assets';
+ },
+ 'api' => function (array $urls) {
+ return $urls['base'] . '/' . ($this->options['api']['slug'] ?? 'api');
+ },
+ 'media' => function (array $urls) {
+ return $urls['base'] . '/media';
+ },
+ 'panel' => function (array $urls) {
+ return $urls['base'] . '/' . ($this->options['panel']['slug'] ?? 'panel');
+ }
+];
diff --git a/kirby/dependencies/parsedown-extra/ParsedownExtra.php b/kirby/dependencies/parsedown-extra/ParsedownExtra.php
new file mode 100755
index 0000000..9e1a748
--- /dev/null
+++ b/kirby/dependencies/parsedown-extra/ParsedownExtra.php
@@ -0,0 +1,624 @@
+BlockTypes[':'] []= 'DefinitionList';
+ $this->BlockTypes['*'] []= 'Abbreviation';
+
+ # identify footnote definitions before reference definitions
+ array_unshift($this->BlockTypes['['], 'Footnote');
+
+ # identify footnote markers before before links
+ array_unshift($this->InlineTypes['['], 'FootnoteMarker');
+ }
+
+ #
+ # ~
+
+ public function text($text)
+ {
+ $Elements = $this->textElements($text);
+
+ # convert to markup
+ $markup = $this->elements($Elements);
+
+ # trim line breaks
+ $markup = trim($markup, "\n");
+
+ # merge consecutive dl elements
+
+ $markup = preg_replace('/<\/dl>\s+
al idioma por defecto? Esto no se puede deshacer.\s+/', '', $markup);
+
+ # add footnotes
+
+ if (isset($this->DefinitionData['Footnote'])) {
+ $Element = $this->buildFootnoteElement();
+
+ $markup .= "\n" . $this->element($Element);
+ }
+
+ return $markup;
+ }
+
+ #
+ # Blocks
+ #
+
+ #
+ # Abbreviation
+
+ protected function blockAbbreviation($Line)
+ {
+ if (preg_match('/^\*\[(.+?)\]:[ ]*(.+?)[ ]*$/', $Line['text'], $matches)) {
+ $this->DefinitionData['Abbreviation'][$matches[1]] = $matches[2];
+
+ $Block = array(
+ 'hidden' => true,
+ );
+
+ return $Block;
+ }
+ }
+
+ #
+ # Footnote
+
+ protected function blockFootnote($Line)
+ {
+ if (preg_match('/^\[\^(.+?)\]:[ ]?(.*)$/', $Line['text'], $matches)) {
+ $Block = array(
+ 'label' => $matches[1],
+ 'text' => $matches[2],
+ 'hidden' => true,
+ );
+
+ return $Block;
+ }
+ }
+
+ protected function blockFootnoteContinue($Line, $Block)
+ {
+ if ($Line['text'][0] === '[' and preg_match('/^\[\^(.+?)\]:/', $Line['text'])) {
+ return;
+ }
+
+ if (isset($Block['interrupted'])) {
+ if ($Line['indent'] >= 4) {
+ $Block['text'] .= "\n\n" . $Line['text'];
+
+ return $Block;
+ }
+ } else {
+ $Block['text'] .= "\n" . $Line['text'];
+
+ return $Block;
+ }
+ }
+
+ protected function blockFootnoteComplete($Block)
+ {
+ $this->DefinitionData['Footnote'][$Block['label']] = array(
+ 'text' => $Block['text'],
+ 'count' => null,
+ 'number' => null,
+ );
+
+ return $Block;
+ }
+
+ #
+ # Definition List
+
+ protected function blockDefinitionList($Line, $Block)
+ {
+ if (! isset($Block) or $Block['type'] !== 'Paragraph') {
+ return;
+ }
+
+ $Element = array(
+ 'name' => 'dl',
+ 'elements' => array(),
+ );
+
+ $terms = explode("\n", $Block['element']['handler']['argument']);
+
+ foreach ($terms as $term) {
+ $Element['elements'] []= array(
+ 'name' => 'dt',
+ 'handler' => array(
+ 'function' => 'lineElements',
+ 'argument' => $term,
+ 'destination' => 'elements'
+ ),
+ );
+ }
+
+ $Block['element'] = $Element;
+
+ $Block = $this->addDdElement($Line, $Block);
+
+ return $Block;
+ }
+
+ protected function blockDefinitionListContinue($Line, array $Block)
+ {
+ if ($Line['text'][0] === ':') {
+ $Block = $this->addDdElement($Line, $Block);
+
+ return $Block;
+ } else {
+ if (isset($Block['interrupted']) and $Line['indent'] === 0) {
+ return;
+ }
+
+ if (isset($Block['interrupted'])) {
+ $Block['dd']['handler']['function'] = 'textElements';
+ $Block['dd']['handler']['argument'] .= "\n\n";
+
+ $Block['dd']['handler']['destination'] = 'elements';
+
+ unset($Block['interrupted']);
+ }
+
+ $text = substr($Line['body'], min($Line['indent'], 4));
+
+ $Block['dd']['handler']['argument'] .= "\n" . $text;
+
+ return $Block;
+ }
+ }
+
+ #
+ # Header
+
+ protected function blockHeader($Line)
+ {
+ $Block = parent::blockHeader($Line);
+
+ if (preg_match('/[ #]*{('.$this->regexAttribute.'+)}[ ]*$/', $Block['element']['handler']['argument'], $matches, PREG_OFFSET_CAPTURE)) {
+ $attributeString = $matches[1][0];
+
+ $Block['element']['attributes'] = $this->parseAttributeData($attributeString);
+
+ $Block['element']['handler']['argument'] = substr($Block['element']['handler']['argument'], 0, $matches[0][1]);
+ }
+
+ return $Block;
+ }
+
+ #
+ # Markup
+
+ protected function blockMarkup($Line)
+ {
+ if ($this->markupEscaped or $this->safeMode) {
+ return;
+ }
+
+ if (preg_match('/^<(\w[\w-]*)(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*(\/)?>/', $Line['text'], $matches)) {
+ $element = strtolower($matches[1]);
+
+ if (in_array($element, $this->textLevelElements)) {
+ return;
+ }
+
+ $Block = array(
+ 'name' => $matches[1],
+ 'depth' => 0,
+ 'element' => array(
+ 'rawHtml' => $Line['text'],
+ 'autobreak' => true,
+ ),
+ );
+
+ $length = strlen($matches[0]);
+ $remainder = substr($Line['text'], $length);
+
+ if (trim($remainder) === '') {
+ if (isset($matches[2]) or in_array($matches[1], $this->voidElements)) {
+ $Block['closed'] = true;
+ $Block['void'] = true;
+ }
+ } else {
+ if (isset($matches[2]) or in_array($matches[1], $this->voidElements)) {
+ return;
+ }
+ if (preg_match('/<\/'.$matches[1].'>[ ]*$/i', $remainder)) {
+ $Block['closed'] = true;
+ }
+ }
+
+ return $Block;
+ }
+ }
+
+ protected function blockMarkupContinue($Line, array $Block)
+ {
+ if (isset($Block['closed'])) {
+ return;
+ }
+
+ if (preg_match('/^<'.$Block['name'].'(?:[ ]*'.$this->regexHtmlAttribute.')*[ ]*>/i', $Line['text'])) { # open
+ $Block['depth'] ++;
+ }
+
+ if (preg_match('/(.*?)<\/'.$Block['name'].'>[ ]*$/i', $Line['text'], $matches)) { # close
+ if ($Block['depth'] > 0) {
+ $Block['depth'] --;
+ } else {
+ $Block['closed'] = true;
+ }
+ }
+
+ if (isset($Block['interrupted'])) {
+ $Block['element']['rawHtml'] .= "\n";
+ unset($Block['interrupted']);
+ }
+
+ $Block['element']['rawHtml'] .= "\n".$Line['body'];
+
+ return $Block;
+ }
+
+ protected function blockMarkupComplete($Block)
+ {
+ if (! isset($Block['void'])) {
+ $Block['element']['rawHtml'] = $this->processTag($Block['element']['rawHtml']);
+ }
+
+ return $Block;
+ }
+
+ #
+ # Setext
+
+ protected function blockSetextHeader($Line, array $Block = null)
+ {
+ $Block = parent::blockSetextHeader($Line, $Block);
+
+ if (preg_match('/[ ]*{('.$this->regexAttribute.'+)}[ ]*$/', $Block['element']['handler']['argument'], $matches, PREG_OFFSET_CAPTURE)) {
+ $attributeString = $matches[1][0];
+
+ $Block['element']['attributes'] = $this->parseAttributeData($attributeString);
+
+ $Block['element']['handler']['argument'] = substr($Block['element']['handler']['argument'], 0, $matches[0][1]);
+ }
+
+ return $Block;
+ }
+
+ #
+ # Inline Elements
+ #
+
+ #
+ # Footnote Marker
+
+ protected function inlineFootnoteMarker($Excerpt)
+ {
+ if (preg_match('/^\[\^(.+?)\]/', $Excerpt['text'], $matches)) {
+ $name = $matches[1];
+
+ if (! isset($this->DefinitionData['Footnote'][$name])) {
+ return;
+ }
+
+ $this->DefinitionData['Footnote'][$name]['count'] ++;
+
+ if (! isset($this->DefinitionData['Footnote'][$name]['number'])) {
+ $this->DefinitionData['Footnote'][$name]['number'] = ++ $this->footnoteCount; # » &
+ }
+
+ $Element = array(
+ 'name' => 'sup',
+ 'attributes' => array('id' => 'fnref'.$this->DefinitionData['Footnote'][$name]['count'].':'.$name),
+ 'element' => array(
+ 'name' => 'a',
+ 'attributes' => array('href' => '#fn:'.$name, 'class' => 'footnote-ref'),
+ 'text' => $this->DefinitionData['Footnote'][$name]['number'],
+ ),
+ );
+
+ return array(
+ 'extent' => strlen($matches[0]),
+ 'element' => $Element,
+ );
+ }
+ }
+
+ private $footnoteCount = 0;
+
+ #
+ # Link
+
+ protected function inlineLink($Excerpt)
+ {
+ $Link = parent::inlineLink($Excerpt);
+
+ $remainder = substr($Excerpt['text'], $Link['extent']);
+
+ if (preg_match('/^[ ]*{('.$this->regexAttribute.'+)}/', $remainder, $matches)) {
+ $Link['element']['attributes'] += $this->parseAttributeData($matches[1]);
+
+ $Link['extent'] += strlen($matches[0]);
+ }
+
+ return $Link;
+ }
+
+ #
+ # ~
+ #
+
+ private $currentAbreviation;
+ private $currentMeaning;
+
+ protected function insertAbreviation(array $Element)
+ {
+ if (isset($Element['text'])) {
+ $Element['elements'] = self::pregReplaceElements(
+ '/\b'.preg_quote($this->currentAbreviation, '/').'\b/',
+ array(
+ array(
+ 'name' => 'abbr',
+ 'attributes' => array(
+ 'title' => $this->currentMeaning,
+ ),
+ 'text' => $this->currentAbreviation,
+ )
+ ),
+ $Element['text']
+ );
+
+ unset($Element['text']);
+ }
+
+ return $Element;
+ }
+
+ protected function inlineText($text)
+ {
+ $Inline = parent::inlineText($text);
+
+ if (isset($this->DefinitionData['Abbreviation'])) {
+ foreach ($this->DefinitionData['Abbreviation'] as $abbreviation => $meaning) {
+ $this->currentAbreviation = $abbreviation;
+ $this->currentMeaning = $meaning;
+
+ $Inline['element'] = $this->elementApplyRecursiveDepthFirst(
+ array($this, 'insertAbreviation'),
+ $Inline['element']
+ );
+ }
+ }
+
+ return $Inline;
+ }
+
+ #
+ # Util Methods
+ #
+
+ protected function addDdElement(array $Line, array $Block)
+ {
+ $text = substr($Line['text'], 1);
+ $text = trim($text);
+
+ unset($Block['dd']);
+
+ $Block['dd'] = array(
+ 'name' => 'dd',
+ 'handler' => array(
+ 'function' => 'lineElements',
+ 'argument' => $text,
+ 'destination' => 'elements'
+ ),
+ );
+
+ if (isset($Block['interrupted'])) {
+ $Block['dd']['handler']['function'] = 'textElements';
+
+ unset($Block['interrupted']);
+ }
+
+ $Block['element']['elements'] []= & $Block['dd'];
+
+ return $Block;
+ }
+
+ protected function buildFootnoteElement()
+ {
+ $Element = array(
+ 'name' => 'div',
+ 'attributes' => array('class' => 'footnotes'),
+ 'elements' => array(
+ array('name' => 'hr'),
+ array(
+ 'name' => 'ol',
+ 'elements' => array(),
+ ),
+ ),
+ );
+
+ uasort($this->DefinitionData['Footnote'], 'self::sortFootnotes');
+
+ foreach ($this->DefinitionData['Footnote'] as $definitionId => $DefinitionData) {
+ if (! isset($DefinitionData['number'])) {
+ continue;
+ }
+
+ $text = $DefinitionData['text'];
+
+ $textElements = parent::textElements($text);
+
+ $numbers = range(1, $DefinitionData['count']);
+
+ $backLinkElements = array();
+
+ foreach ($numbers as $number) {
+ $backLinkElements[] = array('text' => ' ');
+ $backLinkElements[] = array(
+ 'name' => 'a',
+ 'attributes' => array(
+ 'href' => "#fnref$number:$definitionId",
+ 'rev' => 'footnote',
+ 'class' => 'footnote-backref',
+ ),
+ 'rawHtml' => '↩',
+ 'allowRawHtmlInSafeMode' => true,
+ 'autobreak' => false,
+ );
+ }
+
+ unset($backLinkElements[0]);
+
+ $n = count($textElements) -1;
+
+ if ($textElements[$n]['name'] === 'p') {
+ $backLinkElements = array_merge(
+ array(
+ array(
+ 'rawHtml' => ' ',
+ 'allowRawHtmlInSafeMode' => true,
+ ),
+ ),
+ $backLinkElements
+ );
+
+ unset($textElements[$n]['name']);
+
+ $textElements[$n] = array(
+ 'name' => 'p',
+ 'elements' => array_merge(
+ array($textElements[$n]),
+ $backLinkElements
+ ),
+ );
+ } else {
+ $textElements[] = array(
+ 'name' => 'p',
+ 'elements' => $backLinkElements
+ );
+ }
+
+ $Element['elements'][1]['elements'] []= array(
+ 'name' => 'li',
+ 'attributes' => array('id' => 'fn:'.$definitionId),
+ 'elements' => array_merge(
+ $textElements
+ ),
+ );
+ }
+
+ return $Element;
+ }
+
+ # ~
+
+ protected function parseAttributeData($attributeString)
+ {
+ $Data = array();
+
+ $attributes = preg_split('/[ ]+/', $attributeString, - 1, PREG_SPLIT_NO_EMPTY);
+
+ foreach ($attributes as $attribute) {
+ if ($attribute[0] === '#') {
+ $Data['id'] = substr($attribute, 1);
+ } else { # "."
+ $classes []= substr($attribute, 1);
+ }
+ }
+
+ if (isset($classes)) {
+ $Data['class'] = implode(' ', $classes);
+ }
+
+ return $Data;
+ }
+
+ # ~
+
+ protected function processTag($elementMarkup) # recursive
+ {
+ # http://stackoverflow.com/q/1148928/200145
+ libxml_use_internal_errors(true);
+
+ $DOMDocument = new DOMDocument;
+
+ # http://stackoverflow.com/q/11309194/200145
+ $elementMarkup = mb_convert_encoding($elementMarkup, 'HTML-ENTITIES', 'UTF-8');
+
+ # http://stackoverflow.com/q/4879946/200145
+ $DOMDocument->loadHTML($elementMarkup);
+ $DOMDocument->removeChild($DOMDocument->doctype);
+ $DOMDocument->replaceChild($DOMDocument->firstChild->firstChild->firstChild, $DOMDocument->firstChild);
+
+ $elementText = '';
+
+ if ($DOMDocument->documentElement->getAttribute('markdown') === '1') {
+ foreach ($DOMDocument->documentElement->childNodes as $Node) {
+ $elementText .= $DOMDocument->saveHTML($Node);
+ }
+
+ $DOMDocument->documentElement->removeAttribute('markdown');
+
+ $elementText = "\n".$this->text($elementText)."\n";
+ } else {
+ foreach ($DOMDocument->documentElement->childNodes as $Node) {
+ $nodeMarkup = $DOMDocument->saveHTML($Node);
+
+ if ($Node instanceof DOMElement and ! in_array($Node->nodeName, $this->textLevelElements)) {
+ $elementText .= $this->processTag($nodeMarkup);
+ } else {
+ $elementText .= $nodeMarkup;
+ }
+ }
+ }
+
+ # because we don't want for markup to get encoded
+ $DOMDocument->documentElement->nodeValue = 'placeholder\x1A';
+
+ $markup = $DOMDocument->saveHTML($DOMDocument->documentElement);
+ $markup = str_replace('placeholder\x1A', $elementText, $markup);
+
+ return $markup;
+ }
+
+ # ~
+
+ protected function sortFootnotes($A, $B) # callback
+ {
+ return $A['number'] - $B['number'];
+ }
+
+ #
+ # Fields
+ #
+
+ protected $regexAttribute = '(?:[#.][-\w]+[ ]*)';
+}
diff --git a/kirby/dependencies/parsedown/Parsedown.php b/kirby/dependencies/parsedown/Parsedown.php
new file mode 100755
index 0000000..6552c0c
--- /dev/null
+++ b/kirby/dependencies/parsedown/Parsedown.php
@@ -0,0 +1,1822 @@
+textElements($text);
+
+ # convert to markup
+ $markup = $this->elements($Elements);
+
+ # trim line breaks
+ $markup = trim($markup, "\n");
+
+ return $markup;
+ }
+
+ protected function textElements($text)
+ {
+ # make sure no definitions are set
+ $this->DefinitionData = array();
+
+ # standardize line breaks
+ $text = str_replace(array("\r\n", "\r"), "\n", $text);
+
+ # remove surrounding line breaks
+ $text = trim($text, "\n");
+
+ # split text into lines
+ $lines = explode("\n", $text);
+
+ # iterate through lines to identify blocks
+ return $this->linesElements($lines);
+ }
+
+ #
+ # Setters
+ #
+
+ public function setBreaksEnabled($breaksEnabled)
+ {
+ $this->breaksEnabled = $breaksEnabled;
+
+ return $this;
+ }
+
+ protected $breaksEnabled;
+
+ public function setMarkupEscaped($markupEscaped)
+ {
+ $this->markupEscaped = $markupEscaped;
+
+ return $this;
+ }
+
+ protected $markupEscaped;
+
+ public function setUrlsLinked($urlsLinked)
+ {
+ $this->urlsLinked = $urlsLinked;
+
+ return $this;
+ }
+
+ protected $urlsLinked = true;
+
+ public function setSafeMode($safeMode)
+ {
+ $this->safeMode = (bool) $safeMode;
+
+ return $this;
+ }
+
+ protected $safeMode;
+
+ public function setStrictMode($strictMode)
+ {
+ $this->strictMode = (bool) $strictMode;
+
+ return $this;
+ }
+
+ protected $strictMode;
+
+ protected $safeLinksWhitelist = array(
+ 'http://',
+ 'https://',
+ 'ftp://',
+ 'ftps://',
+ 'mailto:',
+ 'data:image/png;base64,',
+ 'data:image/gif;base64,',
+ 'data:image/jpeg;base64,',
+ 'irc:',
+ 'ircs:',
+ 'git:',
+ 'ssh:',
+ 'news:',
+ 'steam:',
+ );
+
+ #
+ # Lines
+ #
+
+ protected $BlockTypes = array(
+ '#' => array('Header'),
+ '*' => array('Rule', 'List'),
+ '+' => array('List'),
+ '-' => array('SetextHeader', 'Table', 'Rule', 'List'),
+ '0' => array('List'),
+ '1' => array('List'),
+ '2' => array('List'),
+ '3' => array('List'),
+ '4' => array('List'),
+ '5' => array('List'),
+ '6' => array('List'),
+ '7' => array('List'),
+ '8' => array('List'),
+ '9' => array('List'),
+ ':' => array('Table'),
+ '<' => array('Comment', 'Markup'),
+ '=' => array('SetextHeader'),
+ '>' => array('Quote'),
+ '[' => array('Reference'),
+ '_' => array('Rule'),
+ '`' => array('FencedCode'),
+ '|' => array('Table'),
+ '~' => array('FencedCode'),
+ );
+
+ # ~
+
+ protected $unmarkedBlockTypes = array(
+ 'Code',
+ );
+
+ #
+ # Blocks
+ #
+
+ protected function lines(array $lines)
+ {
+ return $this->elements($this->linesElements($lines));
+ }
+
+ protected function linesElements(array $lines)
+ {
+ $Elements = array();
+ $CurrentBlock = null;
+
+ foreach ($lines as $line) {
+ if (chop($line) === '') {
+ if (isset($CurrentBlock)) {
+ $CurrentBlock['interrupted'] = (
+ isset($CurrentBlock['interrupted'])
+ ? $CurrentBlock['interrupted'] + 1 : 1
+ );
+ }
+
+ continue;
+ }
+
+ while (($beforeTab = strstr($line, "\t", true)) !== false) {
+ $shortage = 4 - mb_strlen($beforeTab, 'utf-8') % 4;
+
+ $line = $beforeTab
+ . str_repeat(' ', $shortage)
+ . substr($line, strlen($beforeTab) + 1)
+ ;
+ }
+
+ $indent = strspn($line, ' ');
+
+ $text = $indent > 0 ? substr($line, $indent) : $line;
+
+ # ~
+
+ $Line = array('body' => $line, 'indent' => $indent, 'text' => $text);
+
+ # ~
+
+ if (isset($CurrentBlock['continuable'])) {
+ $methodName = 'block' . $CurrentBlock['type'] . 'Continue';
+ $Block = $this->$methodName($Line, $CurrentBlock);
+
+ if (isset($Block)) {
+ $CurrentBlock = $Block;
+
+ continue;
+ } else {
+ if ($this->isBlockCompletable($CurrentBlock['type'])) {
+ $methodName = 'block' . $CurrentBlock['type'] . 'Complete';
+ $CurrentBlock = $this->$methodName($CurrentBlock);
+ }
+ }
+ }
+
+ # ~
+
+ $marker = $text[0];
+
+ # ~
+
+ $blockTypes = $this->unmarkedBlockTypes;
+
+ if (isset($this->BlockTypes[$marker])) {
+ foreach ($this->BlockTypes[$marker] as $blockType) {
+ $blockTypes []= $blockType;
+ }
+ }
+
+ #
+ # ~
+
+ foreach ($blockTypes as $blockType) {
+ $Block = $this->{"block$blockType"}($Line, $CurrentBlock);
+
+ if (isset($Block)) {
+ $Block['type'] = $blockType;
+
+ if (! isset($Block['identified'])) {
+ if (isset($CurrentBlock)) {
+ $Elements[] = $this->extractElement($CurrentBlock);
+ }
+
+ $Block['identified'] = true;
+ }
+
+ if ($this->isBlockContinuable($blockType)) {
+ $Block['continuable'] = true;
+ }
+
+ $CurrentBlock = $Block;
+
+ continue 2;
+ }
+ }
+
+ # ~
+
+ if (isset($CurrentBlock) and $CurrentBlock['type'] === 'Paragraph') {
+ $Block = $this->paragraphContinue($Line, $CurrentBlock);
+ }
+
+ if (isset($Block)) {
+ $CurrentBlock = $Block;
+ } else {
+ if (isset($CurrentBlock)) {
+ $Elements[] = $this->extractElement($CurrentBlock);
+ }
+
+ $CurrentBlock = $this->paragraph($Line);
+
+ $CurrentBlock['identified'] = true;
+ }
+ }
+
+ # ~
+
+ if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type'])) {
+ $methodName = 'block' . $CurrentBlock['type'] . 'Complete';
+ $CurrentBlock = $this->$methodName($CurrentBlock);
+ }
+
+ # ~
+
+ if (isset($CurrentBlock)) {
+ $Elements[] = $this->extractElement($CurrentBlock);
+ }
+
+ # ~
+
+ return $Elements;
+ }
+
+ protected function extractElement(array $Component)
+ {
+ if (! isset($Component['element'])) {
+ if (isset($Component['markup'])) {
+ $Component['element'] = array('rawHtml' => $Component['markup']);
+ } elseif (isset($Component['hidden'])) {
+ $Component['element'] = array();
+ }
+ }
+
+ return $Component['element'];
+ }
+
+ protected function isBlockContinuable($Type)
+ {
+ return method_exists($this, 'block' . $Type . 'Continue');
+ }
+
+ protected function isBlockCompletable($Type)
+ {
+ return method_exists($this, 'block' . $Type . 'Complete');
+ }
+
+ #
+ # Code
+
+ protected function blockCode($Line, $Block = null)
+ {
+ if (isset($Block) and $Block['type'] === 'Paragraph' and ! isset($Block['interrupted'])) {
+ return;
+ }
+
+ if ($Line['indent'] >= 4) {
+ $text = substr($Line['body'], 4);
+
+ $Block = array(
+ 'element' => array(
+ 'name' => 'pre',
+ 'element' => array(
+ 'name' => 'code',
+ 'text' => $text,
+ ),
+ ),
+ );
+
+ return $Block;
+ }
+ }
+
+ protected function blockCodeContinue($Line, $Block)
+ {
+ if ($Line['indent'] >= 4) {
+ if (isset($Block['interrupted'])) {
+ $Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']);
+
+ unset($Block['interrupted']);
+ }
+
+ $Block['element']['element']['text'] .= "\n";
+
+ $text = substr($Line['body'], 4);
+
+ $Block['element']['element']['text'] .= $text;
+
+ return $Block;
+ }
+ }
+
+ protected function blockCodeComplete($Block)
+ {
+ return $Block;
+ }
+
+ #
+ # Comment
+
+ protected function blockComment($Line)
+ {
+ if ($this->markupEscaped or $this->safeMode) {
+ return;
+ }
+
+ if (strpos($Line['text'], '') !== false) {
+ $Block['closed'] = true;
+ }
+
+ return $Block;
+ }
+ }
+
+ protected function blockCommentContinue($Line, array $Block)
+ {
+ if (isset($Block['closed'])) {
+ return;
+ }
+
+ $Block['element']['rawHtml'] .= "\n" . $Line['body'];
+
+ if (strpos($Line['text'], '-->') !== false) {
+ $Block['closed'] = true;
+ }
+
+ return $Block;
+ }
+
+ #
+ # Fenced Code
+
+ protected function blockFencedCode($Line)
+ {
+ $marker = $Line['text'][0];
+
+ $openerLength = strspn($Line['text'], $marker);
+
+ if ($openerLength < 3) {
+ return;
+ }
+
+ $infostring = trim(substr($Line['text'], $openerLength), "\t ");
+
+ if (strpos($infostring, '`') !== false) {
+ return;
+ }
+
+ $Element = array(
+ 'name' => 'code',
+ 'text' => '',
+ );
+
+ if ($infostring !== '') {
+ /**
+ * https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#classes
+ * Every HTML element may have a class attribute specified.
+ * The attribute, if specified, must have a value that is a set
+ * of space-separated tokens representing the various classes
+ * that the element belongs to.
+ * [...]
+ * The space characters, for the purposes of this specification,
+ * are U+0020 SPACE, U+0009 CHARACTER TABULATION (tab),
+ * U+000A LINE FEED (LF), U+000C FORM FEED (FF), and
+ * U+000D CARRIAGE RETURN (CR).
+ */
+ $language = substr($infostring, 0, strcspn($infostring, " \t\n\f\r"));
+
+ $Element['attributes'] = array('class' => "language-$language");
+ }
+
+ $Block = array(
+ 'char' => $marker,
+ 'openerLength' => $openerLength,
+ 'element' => array(
+ 'name' => 'pre',
+ 'element' => $Element,
+ ),
+ );
+
+ return $Block;
+ }
+
+ protected function blockFencedCodeContinue($Line, $Block)
+ {
+ if (isset($Block['complete'])) {
+ return;
+ }
+
+ if (isset($Block['interrupted'])) {
+ $Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']);
+
+ unset($Block['interrupted']);
+ }
+
+ if (($len = strspn($Line['text'], $Block['char'])) >= $Block['openerLength']
+ and chop(substr($Line['text'], $len), ' ') === ''
+ ) {
+ $Block['element']['element']['text'] = substr($Block['element']['element']['text'], 1);
+
+ $Block['complete'] = true;
+
+ return $Block;
+ }
+
+ $Block['element']['element']['text'] .= "\n" . $Line['body'];
+
+ return $Block;
+ }
+
+ protected function blockFencedCodeComplete($Block)
+ {
+ return $Block;
+ }
+
+ #
+ # Header
+
+ protected function blockHeader($Line)
+ {
+ $level = strspn($Line['text'], '#');
+
+ if ($level > 6) {
+ return;
+ }
+
+ $text = trim($Line['text'], '#');
+
+ if ($this->strictMode and isset($text[0]) and $text[0] !== ' ') {
+ return;
+ }
+
+ $text = trim($text, ' ');
+
+ $Block = array(
+ 'element' => array(
+ 'name' => 'h' . min(6, $level),
+ 'handler' => array(
+ 'function' => 'lineElements',
+ 'argument' => $text,
+ 'destination' => 'elements',
+ )
+ ),
+ );
+
+ return $Block;
+ }
+
+ #
+ # List
+
+ protected function blockList($Line, array $CurrentBlock = null)
+ {
+ list($name, $pattern) = $Line['text'][0] <= '-' ? array('ul', '[*+-]') : array('ol', '[0-9]{1,9}+[.\)]');
+
+ if (preg_match('/^('.$pattern.'([ ]++|$))(.*+)/', $Line['text'], $matches)) {
+ $contentIndent = strlen($matches[2]);
+
+ if ($contentIndent >= 5) {
+ $contentIndent -= 1;
+ $matches[1] = substr($matches[1], 0, -$contentIndent);
+ $matches[3] = str_repeat(' ', $contentIndent) . $matches[3];
+ } elseif ($contentIndent === 0) {
+ $matches[1] .= ' ';
+ }
+
+ $markerWithoutWhitespace = strstr($matches[1], ' ', true);
+
+ $Block = array(
+ 'indent' => $Line['indent'],
+ 'pattern' => $pattern,
+ 'data' => array(
+ 'type' => $name,
+ 'marker' => $matches[1],
+ 'markerType' => ($name === 'ul' ? $markerWithoutWhitespace : substr($markerWithoutWhitespace, -1)),
+ ),
+ 'element' => array(
+ 'name' => $name,
+ 'elements' => array(),
+ ),
+ );
+ $Block['data']['markerTypeRegex'] = preg_quote($Block['data']['markerType'], '/');
+
+ if ($name === 'ol') {
+ $listStart = ltrim(strstr($matches[1], $Block['data']['markerType'], true), '0') ?: '0';
+
+ if ($listStart !== '1') {
+ if (
+ isset($CurrentBlock)
+ and $CurrentBlock['type'] === 'Paragraph'
+ and ! isset($CurrentBlock['interrupted'])
+ ) {
+ return;
+ }
+
+ $Block['element']['attributes'] = array('start' => $listStart);
+ }
+ }
+
+ $Block['li'] = array(
+ 'name' => 'li',
+ 'handler' => array(
+ 'function' => 'li',
+ 'argument' => !empty($matches[3]) ? array($matches[3]) : array(),
+ 'destination' => 'elements'
+ )
+ );
+
+ $Block['element']['elements'] []= & $Block['li'];
+
+ return $Block;
+ }
+ }
+
+ protected function blockListContinue($Line, array $Block)
+ {
+ if (isset($Block['interrupted']) and empty($Block['li']['handler']['argument'])) {
+ return null;
+ }
+
+ $requiredIndent = ($Block['indent'] + strlen($Block['data']['marker']));
+
+ if ($Line['indent'] < $requiredIndent
+ and (
+ (
+ $Block['data']['type'] === 'ol'
+ and preg_match('/^[0-9]++'.$Block['data']['markerTypeRegex'].'(?:[ ]++(.*)|$)/', $Line['text'], $matches)
+ ) or (
+ $Block['data']['type'] === 'ul'
+ and preg_match('/^'.$Block['data']['markerTypeRegex'].'(?:[ ]++(.*)|$)/', $Line['text'], $matches)
+ )
+ )
+ ) {
+ if (isset($Block['interrupted'])) {
+ $Block['li']['handler']['argument'] []= '';
+
+ $Block['loose'] = true;
+
+ unset($Block['interrupted']);
+ }
+
+ unset($Block['li']);
+
+ $text = isset($matches[1]) ? $matches[1] : '';
+
+ $Block['indent'] = $Line['indent'];
+
+ $Block['li'] = array(
+ 'name' => 'li',
+ 'handler' => array(
+ 'function' => 'li',
+ 'argument' => array($text),
+ 'destination' => 'elements'
+ )
+ );
+
+ $Block['element']['elements'] []= & $Block['li'];
+
+ return $Block;
+ } elseif ($Line['indent'] < $requiredIndent and $this->blockList($Line)) {
+ return null;
+ }
+
+ if ($Line['text'][0] === '[' and $this->blockReference($Line)) {
+ return $Block;
+ }
+
+ if ($Line['indent'] >= $requiredIndent) {
+ if (isset($Block['interrupted'])) {
+ $Block['li']['handler']['argument'] []= '';
+
+ $Block['loose'] = true;
+
+ unset($Block['interrupted']);
+ }
+
+ $text = substr($Line['body'], $requiredIndent);
+
+ $Block['li']['handler']['argument'] []= $text;
+
+ return $Block;
+ }
+
+ if (! isset($Block['interrupted'])) {
+ $text = preg_replace('/^[ ]{0,'.$requiredIndent.'}+/', '', $Line['body']);
+
+ $Block['li']['handler']['argument'] []= $text;
+
+ return $Block;
+ }
+ }
+
+ protected function blockListComplete(array $Block)
+ {
+ if (isset($Block['loose'])) {
+ foreach ($Block['element']['elements'] as &$li) {
+ if (end($li['handler']['argument']) !== '') {
+ $li['handler']['argument'] []= '';
+ }
+ }
+ }
+
+ return $Block;
+ }
+
+ #
+ # Quote
+
+ protected function blockQuote($Line)
+ {
+ if (preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches)) {
+ $Block = array(
+ 'element' => array(
+ 'name' => 'blockquote',
+ 'handler' => array(
+ 'function' => 'linesElements',
+ 'argument' => (array) $matches[1],
+ 'destination' => 'elements',
+ )
+ ),
+ );
+
+ return $Block;
+ }
+ }
+
+ protected function blockQuoteContinue($Line, array $Block)
+ {
+ if (isset($Block['interrupted'])) {
+ return;
+ }
+
+ if ($Line['text'][0] === '>' and preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches)) {
+ $Block['element']['handler']['argument'] []= $matches[1];
+
+ return $Block;
+ }
+
+ if (! isset($Block['interrupted'])) {
+ $Block['element']['handler']['argument'] []= $Line['text'];
+
+ return $Block;
+ }
+ }
+
+ #
+ # Rule
+
+ protected function blockRule($Line)
+ {
+ $marker = $Line['text'][0];
+
+ if (substr_count($Line['text'], $marker) >= 3 and chop($Line['text'], " $marker") === '') {
+ $Block = array(
+ 'element' => array(
+ 'name' => 'hr',
+ ),
+ );
+
+ return $Block;
+ }
+ }
+
+ #
+ # Setext
+
+ protected function blockSetextHeader($Line, array $Block = null)
+ {
+ if (! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted'])) {
+ return;
+ }
+
+ if ($Line['indent'] < 4 and chop(chop($Line['text'], ' '), $Line['text'][0]) === '') {
+ $Block['element']['name'] = $Line['text'][0] === '=' ? 'h1' : 'h2';
+
+ return $Block;
+ }
+ }
+
+ #
+ # Markup
+
+ protected function blockMarkup($Line)
+ {
+ if ($this->markupEscaped or $this->safeMode) {
+ return;
+ }
+
+ if (preg_match('/^<[\/]?+(\w*)(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+(\/)?>/', $Line['text'], $matches)) {
+ $element = strtolower($matches[1]);
+
+ if (in_array($element, $this->textLevelElements)) {
+ return;
+ }
+
+ $Block = array(
+ 'name' => $matches[1],
+ 'element' => array(
+ 'rawHtml' => $Line['text'],
+ 'autobreak' => true,
+ ),
+ );
+
+ return $Block;
+ }
+ }
+
+ protected function blockMarkupContinue($Line, array $Block)
+ {
+ if (isset($Block['closed']) or isset($Block['interrupted'])) {
+ return;
+ }
+
+ $Block['element']['rawHtml'] .= "\n" . $Line['body'];
+
+ return $Block;
+ }
+
+ #
+ # Reference
+
+ protected function blockReference($Line)
+ {
+ if (strpos($Line['text'], ']') !== false
+ and preg_match('/^\[(.+?)\]:[ ]*+(\S+?)>?(?:[ ]+["\'(](.+)["\')])?[ ]*+$/', $Line['text'], $matches)
+ ) {
+ $id = strtolower($matches[1]);
+
+ $Data = array(
+ 'url' => $matches[2],
+ 'title' => isset($matches[3]) ? $matches[3] : null,
+ );
+
+ $this->DefinitionData['Reference'][$id] = $Data;
+
+ $Block = array(
+ 'element' => array(),
+ );
+
+ return $Block;
+ }
+ }
+
+ #
+ # Table
+
+ protected function blockTable($Line, array $Block = null)
+ {
+ if (! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted'])) {
+ return;
+ }
+
+ if (
+ strpos($Block['element']['handler']['argument'], '|') === false
+ and strpos($Line['text'], '|') === false
+ and strpos($Line['text'], ':') === false
+ or strpos($Block['element']['handler']['argument'], "\n") !== false
+ ) {
+ return;
+ }
+
+ if (chop($Line['text'], ' -:|') !== '') {
+ return;
+ }
+
+ $alignments = array();
+
+ $divider = $Line['text'];
+
+ $divider = trim($divider);
+ $divider = trim($divider, '|');
+
+ $dividerCells = explode('|', $divider);
+
+ foreach ($dividerCells as $dividerCell) {
+ $dividerCell = trim($dividerCell);
+
+ if ($dividerCell === '') {
+ return;
+ }
+
+ $alignment = null;
+
+ if ($dividerCell[0] === ':') {
+ $alignment = 'left';
+ }
+
+ if (substr($dividerCell, - 1) === ':') {
+ $alignment = $alignment === 'left' ? 'center' : 'right';
+ }
+
+ $alignments []= $alignment;
+ }
+
+ # ~
+
+ $HeaderElements = array();
+
+ $header = $Block['element']['handler']['argument'];
+
+ $header = trim($header);
+ $header = trim($header, '|');
+
+ $headerCells = explode('|', $header);
+
+ if (count($headerCells) !== count($alignments)) {
+ return;
+ }
+
+ foreach ($headerCells as $index => $headerCell) {
+ $headerCell = trim($headerCell);
+
+ $HeaderElement = array(
+ 'name' => 'th',
+ 'handler' => array(
+ 'function' => 'lineElements',
+ 'argument' => $headerCell,
+ 'destination' => 'elements',
+ )
+ );
+
+ if (isset($alignments[$index])) {
+ $alignment = $alignments[$index];
+
+ $HeaderElement['attributes'] = array(
+ 'style' => "text-align: $alignment;",
+ );
+ }
+
+ $HeaderElements []= $HeaderElement;
+ }
+
+ # ~
+
+ $Block = array(
+ 'alignments' => $alignments,
+ 'identified' => true,
+ 'element' => array(
+ 'name' => 'table',
+ 'elements' => array(),
+ ),
+ );
+
+ $Block['element']['elements'] []= array(
+ 'name' => 'thead',
+ );
+
+ $Block['element']['elements'] []= array(
+ 'name' => 'tbody',
+ 'elements' => array(),
+ );
+
+ $Block['element']['elements'][0]['elements'] []= array(
+ 'name' => 'tr',
+ 'elements' => $HeaderElements,
+ );
+
+ return $Block;
+ }
+
+ protected function blockTableContinue($Line, array $Block)
+ {
+ if (isset($Block['interrupted'])) {
+ return;
+ }
+
+ if (count($Block['alignments']) === 1 or $Line['text'][0] === '|' or strpos($Line['text'], '|')) {
+ $Elements = array();
+
+ $row = $Line['text'];
+
+ $row = trim($row);
+ $row = trim($row, '|');
+
+ preg_match_all('/(?:(\\\\[|])|[^|`]|`[^`]++`|`)++/', $row, $matches);
+
+ $cells = array_slice($matches[0], 0, count($Block['alignments']));
+
+ foreach ($cells as $index => $cell) {
+ $cell = trim($cell);
+
+ $Element = array(
+ 'name' => 'td',
+ 'handler' => array(
+ 'function' => 'lineElements',
+ 'argument' => $cell,
+ 'destination' => 'elements',
+ )
+ );
+
+ if (isset($Block['alignments'][$index])) {
+ $Element['attributes'] = array(
+ 'style' => 'text-align: ' . $Block['alignments'][$index] . ';',
+ );
+ }
+
+ $Elements []= $Element;
+ }
+
+ $Element = array(
+ 'name' => 'tr',
+ 'elements' => $Elements,
+ );
+
+ $Block['element']['elements'][1]['elements'] []= $Element;
+
+ return $Block;
+ }
+ }
+
+ #
+ # ~
+ #
+
+ protected function paragraph($Line)
+ {
+ return array(
+ 'type' => 'Paragraph',
+ 'element' => array(
+ 'name' => 'p',
+ 'handler' => array(
+ 'function' => 'lineElements',
+ 'argument' => $Line['text'],
+ 'destination' => 'elements',
+ ),
+ ),
+ );
+ }
+
+ protected function paragraphContinue($Line, array $Block)
+ {
+ if (isset($Block['interrupted'])) {
+ return;
+ }
+
+ $Block['element']['handler']['argument'] .= "\n".$Line['text'];
+
+ return $Block;
+ }
+
+ #
+ # Inline Elements
+ #
+
+ protected $InlineTypes = array(
+ '!' => array('Image'),
+ '&' => array('SpecialCharacter'),
+ '*' => array('Emphasis'),
+ ':' => array('Url'),
+ '<' => array('UrlTag', 'EmailTag', 'Markup'),
+ '[' => array('Link'),
+ '_' => array('Emphasis'),
+ '`' => array('Code'),
+ '~' => array('Strikethrough'),
+ '\\' => array('EscapeSequence'),
+ );
+
+ # ~
+
+ protected $inlineMarkerList = '!*_&[:<`~\\';
+
+ #
+ # ~
+ #
+
+ public function line($text, $nonNestables = array())
+ {
+ return $this->elements($this->lineElements($text, $nonNestables));
+ }
+
+ protected function lineElements($text, $nonNestables = array())
+ {
+ $Elements = array();
+
+ $nonNestables = (
+ empty($nonNestables)
+ ? array()
+ : array_combine($nonNestables, $nonNestables)
+ );
+
+ # $excerpt is based on the first occurrence of a marker
+
+ while ($excerpt = strpbrk($text, $this->inlineMarkerList)) {
+ $marker = $excerpt[0];
+
+ $markerPosition = strlen($text) - strlen($excerpt);
+
+ $Excerpt = array('text' => $excerpt, 'context' => $text);
+
+ foreach ($this->InlineTypes[$marker] as $inlineType) {
+ # check to see if the current inline type is nestable in the current context
+
+ if (isset($nonNestables[$inlineType])) {
+ continue;
+ }
+
+ $Inline = $this->{"inline$inlineType"}($Excerpt);
+
+ if (! isset($Inline)) {
+ continue;
+ }
+
+ # makes sure that the inline belongs to "our" marker
+
+ if (isset($Inline['position']) and $Inline['position'] > $markerPosition) {
+ continue;
+ }
+
+ # sets a default inline position
+
+ if (! isset($Inline['position'])) {
+ $Inline['position'] = $markerPosition;
+ }
+
+ # cause the new element to 'inherit' our non nestables
+
+
+ $Inline['element']['nonNestables'] = isset($Inline['element']['nonNestables'])
+ ? array_merge($Inline['element']['nonNestables'], $nonNestables)
+ : $nonNestables
+ ;
+
+ # the text that comes before the inline
+ $unmarkedText = substr($text, 0, $Inline['position']);
+
+ # compile the unmarked text
+ $InlineText = $this->inlineText($unmarkedText);
+ $Elements[] = $InlineText['element'];
+
+ # compile the inline
+ $Elements[] = $this->extractElement($Inline);
+
+ # remove the examined text
+ $text = substr($text, $Inline['position'] + $Inline['extent']);
+
+ continue 2;
+ }
+
+ # the marker does not belong to an inline
+
+ $unmarkedText = substr($text, 0, $markerPosition + 1);
+
+ $InlineText = $this->inlineText($unmarkedText);
+ $Elements[] = $InlineText['element'];
+
+ $text = substr($text, $markerPosition + 1);
+ }
+
+ $InlineText = $this->inlineText($text);
+ $Elements[] = $InlineText['element'];
+
+ foreach ($Elements as &$Element) {
+ if (! isset($Element['autobreak'])) {
+ $Element['autobreak'] = false;
+ }
+ }
+
+ return $Elements;
+ }
+
+ #
+ # ~
+ #
+
+ protected function inlineText($text)
+ {
+ $Inline = array(
+ 'extent' => strlen($text),
+ 'element' => array(),
+ );
+
+ $Inline['element']['elements'] = self::pregReplaceElements(
+ $this->breaksEnabled ? '/[ ]*+\n/' : '/(?:[ ]*+\\\\|[ ]{2,}+)\n/',
+ array(
+ array('name' => 'br'),
+ array('text' => "\n"),
+ ),
+ $text
+ );
+
+ return $Inline;
+ }
+
+ protected function inlineCode($Excerpt)
+ {
+ $marker = $Excerpt['text'][0];
+
+ if (preg_match('/^(['.$marker.']++)[ ]*+(.+?)[ ]*+(? strlen($matches[0]),
+ 'element' => array(
+ 'name' => 'code',
+ 'text' => $text,
+ ),
+ );
+ }
+ }
+
+ protected function inlineEmailTag($Excerpt)
+ {
+ $hostnameLabel = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?';
+
+ $commonMarkEmail = '[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]++@'
+ . $hostnameLabel . '(?:\.' . $hostnameLabel . ')*';
+
+ if (strpos($Excerpt['text'], '>') !== false
+ and preg_match("/^<((mailto:)?$commonMarkEmail)>/i", $Excerpt['text'], $matches)
+ ) {
+ $url = $matches[1];
+
+ if (! isset($matches[2])) {
+ $url = "mailto:$url";
+ }
+
+ return array(
+ 'extent' => strlen($matches[0]),
+ 'element' => array(
+ 'name' => 'a',
+ 'text' => $matches[1],
+ 'attributes' => array(
+ 'href' => $url,
+ ),
+ ),
+ );
+ }
+ }
+
+ protected function inlineEmphasis($Excerpt)
+ {
+ if (! isset($Excerpt['text'][1])) {
+ return;
+ }
+
+ $marker = $Excerpt['text'][0];
+
+ if ($Excerpt['text'][1] === $marker and preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches)) {
+ $emphasis = 'strong';
+ } elseif (preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches)) {
+ $emphasis = 'em';
+ } else {
+ return;
+ }
+
+ return array(
+ 'extent' => strlen($matches[0]),
+ 'element' => array(
+ 'name' => $emphasis,
+ 'handler' => array(
+ 'function' => 'lineElements',
+ 'argument' => $matches[1],
+ 'destination' => 'elements',
+ )
+ ),
+ );
+ }
+
+ protected function inlineEscapeSequence($Excerpt)
+ {
+ if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters)) {
+ return array(
+ 'element' => array('rawHtml' => $Excerpt['text'][1]),
+ 'extent' => 2,
+ );
+ }
+ }
+
+ protected function inlineImage($Excerpt)
+ {
+ if (! isset($Excerpt['text'][1]) or $Excerpt['text'][1] !== '[') {
+ return;
+ }
+
+ $Excerpt['text']= substr($Excerpt['text'], 1);
+
+ $Link = $this->inlineLink($Excerpt);
+
+ if ($Link === null) {
+ return;
+ }
+
+ $Inline = array(
+ 'extent' => $Link['extent'] + 1,
+ 'element' => array(
+ 'name' => 'img',
+ 'attributes' => array(
+ 'src' => $Link['element']['attributes']['href'],
+ 'alt' => $Link['element']['handler']['argument'],
+ ),
+ 'autobreak' => true,
+ ),
+ );
+
+ $Inline['element']['attributes'] += $Link['element']['attributes'];
+
+ unset($Inline['element']['attributes']['href']);
+
+ return $Inline;
+ }
+
+ protected function inlineLink($Excerpt)
+ {
+ $Element = array(
+ 'name' => 'a',
+ 'handler' => array(
+ 'function' => 'lineElements',
+ 'argument' => null,
+ 'destination' => 'elements',
+ ),
+ 'nonNestables' => array('Url', 'Link'),
+ 'attributes' => array(
+ 'href' => null,
+ 'title' => null,
+ ),
+ );
+
+ $extent = 0;
+
+ $remainder = $Excerpt['text'];
+
+ if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches)) {
+ $Element['handler']['argument'] = $matches[1];
+
+ $extent += strlen($matches[0]);
+
+ $remainder = substr($remainder, $extent);
+ } else {
+ return;
+ }
+
+ if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*+"|\'[^\']*+\'))?\s*+[)]/', $remainder, $matches)) {
+ $Element['attributes']['href'] = $matches[1];
+
+ if (isset($matches[2])) {
+ $Element['attributes']['title'] = substr($matches[2], 1, - 1);
+ }
+
+ $extent += strlen($matches[0]);
+ } else {
+ if (preg_match('/^\s*\[(.*?)\]/', $remainder, $matches)) {
+ $definition = strlen($matches[1]) ? $matches[1] : $Element['handler']['argument'];
+ $definition = strtolower($definition);
+
+ $extent += strlen($matches[0]);
+ } else {
+ $definition = strtolower($Element['handler']['argument']);
+ }
+
+ if (! isset($this->DefinitionData['Reference'][$definition])) {
+ return;
+ }
+
+ $Definition = $this->DefinitionData['Reference'][$definition];
+
+ $Element['attributes']['href'] = $Definition['url'];
+ $Element['attributes']['title'] = $Definition['title'];
+ }
+
+ return array(
+ 'extent' => $extent,
+ 'element' => $Element,
+ );
+ }
+
+ protected function inlineMarkup($Excerpt)
+ {
+ if ($this->markupEscaped or $this->safeMode or strpos($Excerpt['text'], '>') === false) {
+ return;
+ }
+
+ if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w[\w-]*+[ ]*+>/s', $Excerpt['text'], $matches)) {
+ return array(
+ 'element' => array('rawHtml' => $matches[0]),
+ 'extent' => strlen($matches[0]),
+ );
+ }
+
+ if ($Excerpt['text'][1] === '!' and preg_match('/^/s', $Excerpt['text'], $matches)) {
+ return array(
+ 'element' => array('rawHtml' => $matches[0]),
+ 'extent' => strlen($matches[0]),
+ );
+ }
+
+ if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w[\w-]*+(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+\/?>/s', $Excerpt['text'], $matches)) {
+ return array(
+ 'element' => array('rawHtml' => $matches[0]),
+ 'extent' => strlen($matches[0]),
+ );
+ }
+ }
+
+ protected function inlineSpecialCharacter($Excerpt)
+ {
+ if ($Excerpt['text'][1] !== ' ' and strpos($Excerpt['text'], ';') !== false
+ and preg_match('/^&(#?+[0-9a-zA-Z]++);/', $Excerpt['text'], $matches)
+ ) {
+ return array(
+ 'element' => array('rawHtml' => '&' . $matches[1] . ';'),
+ 'extent' => strlen($matches[0]),
+ );
+ }
+
+ return;
+ }
+
+ protected function inlineStrikethrough($Excerpt)
+ {
+ if (! isset($Excerpt['text'][1])) {
+ return;
+ }
+
+ if ($Excerpt['text'][1] === '~' and preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $Excerpt['text'], $matches)) {
+ return array(
+ 'extent' => strlen($matches[0]),
+ 'element' => array(
+ 'name' => 'del',
+ 'handler' => array(
+ 'function' => 'lineElements',
+ 'argument' => $matches[1],
+ 'destination' => 'elements',
+ )
+ ),
+ );
+ }
+ }
+
+ protected function inlineUrl($Excerpt)
+ {
+ if ($this->urlsLinked !== true or ! isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/') {
+ return;
+ }
+
+ if (strpos($Excerpt['context'], 'http') !== false
+ and preg_match('/\bhttps?+:[\/]{2}[^\s<]+\b\/*+/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE)
+ ) {
+ $url = $matches[0][0];
+
+ $Inline = array(
+ 'extent' => strlen($matches[0][0]),
+ 'position' => $matches[0][1],
+ 'element' => array(
+ 'name' => 'a',
+ 'text' => $url,
+ 'attributes' => array(
+ 'href' => $url,
+ ),
+ ),
+ );
+
+ return $Inline;
+ }
+ }
+
+ protected function inlineUrlTag($Excerpt)
+ {
+ if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\w++:\/{2}[^ >]++)>/i', $Excerpt['text'], $matches)) {
+ $url = $matches[1];
+
+ return array(
+ 'extent' => strlen($matches[0]),
+ 'element' => array(
+ 'name' => 'a',
+ 'text' => $url,
+ 'attributes' => array(
+ 'href' => $url,
+ ),
+ ),
+ );
+ }
+ }
+
+ # ~
+
+ protected function unmarkedText($text)
+ {
+ $Inline = $this->inlineText($text);
+ return $this->element($Inline['element']);
+ }
+
+ #
+ # Handlers
+ #
+
+ protected function handle(array $Element)
+ {
+ if (isset($Element['handler'])) {
+ if (!isset($Element['nonNestables'])) {
+ $Element['nonNestables'] = array();
+ }
+
+ if (is_string($Element['handler'])) {
+ $function = $Element['handler'];
+ $argument = $Element['text'];
+ unset($Element['text']);
+ $destination = 'rawHtml';
+ } else {
+ $function = $Element['handler']['function'];
+ $argument = $Element['handler']['argument'];
+ $destination = $Element['handler']['destination'];
+ }
+
+ $Element[$destination] = $this->{$function}($argument, $Element['nonNestables']);
+
+ if ($destination === 'handler') {
+ $Element = $this->handle($Element);
+ }
+
+ unset($Element['handler']);
+ }
+
+ return $Element;
+ }
+
+ protected function handleElementRecursive(array $Element)
+ {
+ return $this->elementApplyRecursive(array($this, 'handle'), $Element);
+ }
+
+ protected function handleElementsRecursive(array $Elements)
+ {
+ return $this->elementsApplyRecursive(array($this, 'handle'), $Elements);
+ }
+
+ protected function elementApplyRecursive($closure, array $Element)
+ {
+ $Element = call_user_func($closure, $Element);
+
+ if (isset($Element['elements'])) {
+ $Element['elements'] = $this->elementsApplyRecursive($closure, $Element['elements']);
+ } elseif (isset($Element['element'])) {
+ $Element['element'] = $this->elementApplyRecursive($closure, $Element['element']);
+ }
+
+ return $Element;
+ }
+
+ protected function elementApplyRecursiveDepthFirst($closure, array $Element)
+ {
+ if (isset($Element['elements'])) {
+ $Element['elements'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['elements']);
+ } elseif (isset($Element['element'])) {
+ $Element['element'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['element']);
+ }
+
+ $Element = call_user_func($closure, $Element);
+
+ return $Element;
+ }
+
+ protected function elementsApplyRecursive($closure, array $Elements)
+ {
+ foreach ($Elements as &$Element) {
+ $Element = $this->elementApplyRecursive($closure, $Element);
+ }
+
+ return $Elements;
+ }
+
+ protected function elementsApplyRecursiveDepthFirst($closure, array $Elements)
+ {
+ foreach ($Elements as &$Element) {
+ $Element = $this->elementApplyRecursiveDepthFirst($closure, $Element);
+ }
+
+ return $Elements;
+ }
+
+ protected function element(array $Element)
+ {
+ if ($this->safeMode) {
+ $Element = $this->sanitiseElement($Element);
+ }
+
+ # identity map if element has no handler
+ $Element = $this->handle($Element);
+
+ $hasName = isset($Element['name']);
+
+ $markup = '';
+
+ if ($hasName) {
+ $markup .= '<' . $Element['name'];
+
+ if (isset($Element['attributes'])) {
+ foreach ($Element['attributes'] as $name => $value) {
+ if ($value === null) {
+ continue;
+ }
+
+ $markup .= " $name=\"".self::escape($value).'"';
+ }
+ }
+ }
+
+ $permitRawHtml = false;
+
+ if (isset($Element['text'])) {
+ $text = $Element['text'];
+ }
+ // very strongly consider an alternative if you're writing an
+ // extension
+ elseif (isset($Element['rawHtml'])) {
+ $text = $Element['rawHtml'];
+
+ $allowRawHtmlInSafeMode = isset($Element['allowRawHtmlInSafeMode']) && $Element['allowRawHtmlInSafeMode'];
+ $permitRawHtml = !$this->safeMode || $allowRawHtmlInSafeMode;
+ }
+
+ $hasContent = isset($text) || isset($Element['element']) || isset($Element['elements']);
+
+ if ($hasContent) {
+ $markup .= $hasName ? '>' : '';
+
+ if (isset($Element['elements'])) {
+ $markup .= $this->elements($Element['elements']);
+ } elseif (isset($Element['element'])) {
+ $markup .= $this->element($Element['element']);
+ } else {
+ if (!$permitRawHtml) {
+ $markup .= self::escape($text, true);
+ } else {
+ $markup .= $text;
+ }
+ }
+
+ $markup .= $hasName ? '' . $Element['name'] . '>' : '';
+ } elseif ($hasName) {
+ $markup .= ' />';
+ }
+
+ return $markup;
+ }
+
+ protected function elements(array $Elements)
+ {
+ $markup = '';
+
+ $autoBreak = true;
+
+ foreach ($Elements as $Element) {
+ if (empty($Element)) {
+ continue;
+ }
+
+ $autoBreakNext = (
+ isset($Element['autobreak'])
+ ? $Element['autobreak'] : isset($Element['name'])
+ );
+ // (autobreak === false) covers both sides of an element
+ $autoBreak = !$autoBreak ? $autoBreak : $autoBreakNext;
+
+ $markup .= ($autoBreak ? "\n" : '') . $this->element($Element);
+ $autoBreak = $autoBreakNext;
+ }
+
+ $markup .= $autoBreak ? "\n" : '';
+
+ return $markup;
+ }
+
+ # ~
+
+ protected function li($lines)
+ {
+ $Elements = $this->linesElements($lines);
+
+ if (! in_array('', $lines)
+ and isset($Elements[0]) and isset($Elements[0]['name'])
+ and $Elements[0]['name'] === 'p'
+ ) {
+ unset($Elements[0]['name']);
+ }
+
+ return $Elements;
+ }
+
+ #
+ # AST Convenience
+ #
+
+ /**
+ * Replace occurrences $regexp with $Elements in $text. Return an array of
+ * elements representing the replacement.
+ */
+ protected static function pregReplaceElements($regexp, $Elements, $text)
+ {
+ $newElements = array();
+
+ while (preg_match($regexp, $text, $matches, PREG_OFFSET_CAPTURE)) {
+ $offset = $matches[0][1];
+ $before = substr($text, 0, $offset);
+ $after = substr($text, $offset + strlen($matches[0][0]));
+
+ $newElements[] = array('text' => $before);
+
+ foreach ($Elements as $Element) {
+ $newElements[] = $Element;
+ }
+
+ $text = $after;
+ }
+
+ $newElements[] = array('text' => $text);
+
+ return $newElements;
+ }
+
+ #
+ # Deprecated Methods
+ #
+
+ public function parse($text)
+ {
+ $markup = $this->text($text);
+
+ return $markup;
+ }
+
+ protected function sanitiseElement(array $Element)
+ {
+ static $goodAttribute = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/';
+ static $safeUrlNameToAtt = array(
+ 'a' => 'href',
+ 'img' => 'src',
+ );
+
+ if (! isset($Element['name'])) {
+ unset($Element['attributes']);
+ return $Element;
+ }
+
+ if (isset($safeUrlNameToAtt[$Element['name']])) {
+ $Element = $this->filterUnsafeUrlInAttribute($Element, $safeUrlNameToAtt[$Element['name']]);
+ }
+
+ if (! empty($Element['attributes'])) {
+ foreach ($Element['attributes'] as $att => $val) {
+ # filter out badly parsed attribute
+ if (! preg_match($goodAttribute, $att)) {
+ unset($Element['attributes'][$att]);
+ }
+ # dump onevent attribute
+ elseif (self::striAtStart($att, 'on')) {
+ unset($Element['attributes'][$att]);
+ }
+ }
+ }
+
+ return $Element;
+ }
+
+ protected function filterUnsafeUrlInAttribute(array $Element, $attribute)
+ {
+ foreach ($this->safeLinksWhitelist as $scheme) {
+ if (self::striAtStart($Element['attributes'][$attribute], $scheme)) {
+ return $Element;
+ }
+ }
+
+ $Element['attributes'][$attribute] = str_replace(':', '%3A', $Element['attributes'][$attribute]);
+
+ return $Element;
+ }
+
+ #
+ # Static Methods
+ #
+
+ protected static function escape($text, $allowQuotes = false)
+ {
+ return htmlspecialchars($text, $allowQuotes ? ENT_NOQUOTES : ENT_QUOTES, 'UTF-8');
+ }
+
+ protected static function striAtStart($string, $needle)
+ {
+ $len = strlen($needle);
+
+ if ($len > strlen($string)) {
+ return false;
+ } else {
+ return strtolower(substr($string, 0, $len)) === strtolower($needle);
+ }
+ }
+
+ public static function instance($name = 'default')
+ {
+ if (isset(self::$instances[$name])) {
+ return self::$instances[$name];
+ }
+
+ $instance = new static();
+
+ self::$instances[$name] = $instance;
+
+ return $instance;
+ }
+
+ private static $instances = array();
+
+ #
+ # Fields
+ #
+
+ protected $DefinitionData;
+
+ #
+ # Read-Only
+
+ protected $specialCharacters = array(
+ '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|', '~'
+ );
+
+ protected $StrongRegex = array(
+ '*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*+[*])+?)[*]{2}(?![*])/s',
+ '_' => '/^__((?:\\\\_|[^_]|_[^_]*+_)+?)__(?!_)/us',
+ );
+
+ protected $EmRegex = array(
+ '*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s',
+ '_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us',
+ );
+
+ protected $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*+(?:\s*+=\s*+(?:[^"\'=<>`\s]+|"[^"]*+"|\'[^\']*+\'))?+';
+
+ protected $voidElements = array(
+ 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source',
+ );
+
+ protected $textLevelElements = array(
+ 'a', 'br', 'bdo', 'abbr', 'blink', 'nextid', 'acronym', 'basefont',
+ 'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing',
+ 'i', 'rp', 'del', 'code', 'strike', 'marquee',
+ 'q', 'rt', 'ins', 'font', 'strong',
+ 's', 'tt', 'kbd', 'mark',
+ 'u', 'xm', 'sub', 'nobr',
+ 'sup', 'ruby',
+ 'var', 'span',
+ 'wbr', 'time',
+ );
+}
diff --git a/kirby/i18n/rules/LICENSE b/kirby/i18n/rules/LICENSE
new file mode 100755
index 0000000..36c3036
--- /dev/null
+++ b/kirby/i18n/rules/LICENSE
@@ -0,0 +1,9 @@
+The MIT License (MIT)
+
+Copyright (c) 2012-217 Florian Eckerstorfer
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/kirby/i18n/rules/ar.json b/kirby/i18n/rules/ar.json
new file mode 100755
index 0000000..e46915f
--- /dev/null
+++ b/kirby/i18n/rules/ar.json
@@ -0,0 +1,30 @@
+{
+ "أ" : "a",
+ "ب" : "b",
+ "ت" : "t",
+ "ث" : "th",
+ "ج" : "g",
+ "ح" : "h",
+ "خ" : "kh",
+ "د" : "d",
+ "ذ" : "th",
+ "ر" : "r",
+ "ز" : "z",
+ "س" : "s",
+ "ش" : "sh",
+ "ص" : "s",
+ "ض" : "d",
+ "ط" : "t",
+ "ظ" : "th",
+ "ع" : "aa",
+ "غ" : "gh",
+ "ف" : "f",
+ "ق" : "k",
+ "ك" : "k",
+ "ل" : "l",
+ "م" : "m",
+ "ن" : "n",
+ "ه" : "h",
+ "و" : "o",
+ "ي" : "y"
+}
diff --git a/kirby/i18n/rules/az.json b/kirby/i18n/rules/az.json
new file mode 100755
index 0000000..ad6e2a9
--- /dev/null
+++ b/kirby/i18n/rules/az.json
@@ -0,0 +1,16 @@
+{
+ "Ə": "E",
+ "Ç": "C",
+ "Ğ": "G",
+ "İ": "I",
+ "Ş": "S",
+ "Ö": "O",
+ "Ü": "U",
+ "ə": "e",
+ "ç": "c",
+ "ğ": "g",
+ "ı": "i",
+ "ş": "s",
+ "ö": "o",
+ "ü": "u"
+}
diff --git a/kirby/i18n/rules/bg.json b/kirby/i18n/rules/bg.json
new file mode 100755
index 0000000..4c45ca1
--- /dev/null
+++ b/kirby/i18n/rules/bg.json
@@ -0,0 +1,65 @@
+{
+ "А": "A",
+ "Б": "B",
+ "В": "V",
+ "Г": "G",
+ "Д": "D",
+ "Е": "E",
+ "Ж": "J",
+ "З": "Z",
+ "И": "I",
+ "Й": "Y",
+ "К": "K",
+ "Л": "L",
+ "М": "M",
+ "Н": "N",
+ "О": "O",
+ "П": "P",
+ "Р": "R",
+ "С": "S",
+ "Т": "T",
+ "У": "U",
+ "Ф": "F",
+ "Х": "H",
+ "Ц": "Ts",
+ "Ч": "Ch",
+ "Ш": "Sh",
+ "Щ": "Sht",
+ "Ъ": "A",
+ "Ь": "I",
+ "Ю": "Iu",
+ "Я": "Ia",
+ "а": "a",
+ "б": "b",
+ "в": "v",
+ "г": "g",
+ "д": "d",
+ "е": "e",
+ "ж": "j",
+ "з": "z",
+ "и": "i",
+ "й": "y",
+ "к": "k",
+ "л": "l",
+ "м": "m",
+ "н": "n",
+ "о": "o",
+ "п": "p",
+ "р": "r",
+ "с": "s",
+ "т": "t",
+ "у": "u",
+ "ф": "f",
+ "х": "h",
+ "ц": "ts",
+ "ч": "ch",
+ "ш": "sh",
+ "щ": "sht",
+ "ъ": "a",
+ "ь": "i",
+ "ю": "iu",
+ "я": "ia",
+ "ия": "ia",
+ "йо": "iо",
+ "ьо": "io"
+}
diff --git a/kirby/i18n/rules/cs.json b/kirby/i18n/rules/cs.json
new file mode 100755
index 0000000..549f805
--- /dev/null
+++ b/kirby/i18n/rules/cs.json
@@ -0,0 +1,20 @@
+{
+ "Č": "C",
+ "Ď": "D",
+ "Ě": "E",
+ "Ň": "N",
+ "Ř": "R",
+ "Š": "S",
+ "Ť": "T",
+ "Ů": "U",
+ "Ž": "Z",
+ "č": "c",
+ "ď": "d",
+ "ě": "e",
+ "ň": "n",
+ "ř": "r",
+ "š": "s",
+ "ť": "t",
+ "ů": "u",
+ "ž": "z"
+}
diff --git a/kirby/i18n/rules/da.json b/kirby/i18n/rules/da.json
new file mode 100755
index 0000000..b88c17c
--- /dev/null
+++ b/kirby/i18n/rules/da.json
@@ -0,0 +1,10 @@
+{
+ "Æ": "Ae",
+ "æ": "ae",
+ "Ø": "Oe",
+ "ø": "oe",
+ "Å": "Aa",
+ "å": "aa",
+ "É": "E",
+ "é": "e"
+}
diff --git a/kirby/i18n/rules/de.json b/kirby/i18n/rules/de.json
new file mode 100755
index 0000000..881b68c
--- /dev/null
+++ b/kirby/i18n/rules/de.json
@@ -0,0 +1,9 @@
+{
+ "Ä": "AE",
+ "Ö": "OE",
+ "Ü": "UE",
+ "ß": "ss",
+ "ä": "ae",
+ "ö": "oe",
+ "ü": "ue"
+}
diff --git a/kirby/i18n/rules/el.json b/kirby/i18n/rules/el.json
new file mode 100755
index 0000000..767a223
--- /dev/null
+++ b/kirby/i18n/rules/el.json
@@ -0,0 +1,111 @@
+{
+ "ΑΥ": "AU",
+ "Αυ": "Au",
+ "ΟΥ": "OU",
+ "Ου": "Ou",
+ "ΕΥ": "EU",
+ "Ευ": "Eu",
+ "ΕΙ": "I",
+ "Ει": "I",
+ "ΟΙ": "I",
+ "Οι": "I",
+ "ΥΙ": "I",
+ "Υι": "I",
+ "ΑΎ": "AU",
+ "Αύ": "Au",
+ "ΟΎ": "OU",
+ "Ού": "Ou",
+ "ΕΎ": "EU",
+ "Εύ": "Eu",
+ "ΕΊ": "I",
+ "Εί": "I",
+ "ΟΊ": "I",
+ "Οί": "I",
+ "ΎΙ": "I",
+ "Ύι": "I",
+ "ΥΊ": "I",
+ "Υί": "I",
+ "αυ": "au",
+ "ου": "ou",
+ "ευ": "eu",
+ "ει": "i",
+ "οι": "i",
+ "υι": "i",
+ "αύ": "au",
+ "ού": "ou",
+ "εύ": "eu",
+ "εί": "i",
+ "οί": "i",
+ "ύι": "i",
+ "υί": "i",
+ "Α": "A",
+ "Β": "V",
+ "Γ": "G",
+ "Δ": "D",
+ "Ε": "E",
+ "Ζ": "Z",
+ "Η": "I",
+ "Θ": "Th",
+ "Ι": "I",
+ "Κ": "K",
+ "Λ": "L",
+ "Μ": "M",
+ "Ν": "N",
+ "Ξ": "X",
+ "Ο": "O",
+ "Π": "P",
+ "Ρ": "R",
+ "Σ": "S",
+ "Τ": "T",
+ "Υ": "I",
+ "Φ": "F",
+ "Χ": "Ch",
+ "Ψ": "Ps",
+ "Ω": "O",
+ "Ά": "A",
+ "Έ": "E",
+ "Ή": "I",
+ "Ί": "I",
+ "Ό": "O",
+ "Ύ": "I",
+ "Ϊ": "I",
+ "Ϋ": "I",
+ "ϒ": "I",
+ "α": "a",
+ "β": "v",
+ "γ": "g",
+ "δ": "d",
+ "ε": "e",
+ "ζ": "z",
+ "η": "i",
+ "θ": "th",
+ "ι": "i",
+ "κ": "k",
+ "λ": "l",
+ "μ": "m",
+ "ν": "n",
+ "ξ": "x",
+ "ο": "o",
+ "π": "p",
+ "ρ": "r",
+ "ς": "s",
+ "σ": "s",
+ "τ": "t",
+ "υ": "i",
+ "φ": "f",
+ "χ": "ch",
+ "ψ": "ps",
+ "ω": "o",
+ "ά": "a",
+ "έ": "e",
+ "ή": "i",
+ "ί": "i",
+ "ό": "o",
+ "ύ": "i",
+ "ϊ": "i",
+ "ϋ": "i",
+ "ΰ": "i",
+ "ώ": "o",
+ "ϐ": "v",
+ "ϑ": "th"
+}
diff --git a/kirby/i18n/rules/eo.json b/kirby/i18n/rules/eo.json
new file mode 100755
index 0000000..9a4e658
--- /dev/null
+++ b/kirby/i18n/rules/eo.json
@@ -0,0 +1,14 @@
+{
+ "ĉ": "cx",
+ "ĝ": "gx",
+ "ĥ": "hx",
+ "ĵ": "jx",
+ "ŝ": "sx",
+ "ŭ": "ux",
+ "Ĉ": "CX",
+ "Ĝ": "GX",
+ "Ĥ": "HX",
+ "Ĵ": "JX",
+ "Ŝ": "SX",
+ "Ŭ": "UX"
+}
diff --git a/kirby/i18n/rules/et.json b/kirby/i18n/rules/et.json
new file mode 100755
index 0000000..fcea469
--- /dev/null
+++ b/kirby/i18n/rules/et.json
@@ -0,0 +1,14 @@
+{
+ "Š": "S",
+ "Ž": "Z",
+ "Õ": "O",
+ "Ä": "A",
+ "Ö": "O",
+ "Ü": "U",
+ "š": "s",
+ "ž": "z",
+ "õ": "o",
+ "ä": "a",
+ "ö": "o",
+ "ü": "u"
+}
\ No newline at end of file
diff --git a/kirby/i18n/rules/fa.json b/kirby/i18n/rules/fa.json
new file mode 100755
index 0000000..0448016
--- /dev/null
+++ b/kirby/i18n/rules/fa.json
@@ -0,0 +1,36 @@
+{
+ "آ" : "A",
+ "ا" : "a",
+ "ب" : "b",
+ "پ" : "p",
+ "ت" : "t",
+ "ث" : "th",
+ "ج" : "j",
+ "چ" : "ch",
+ "ح" : "h",
+ "خ" : "kh",
+ "د" : "d",
+ "ذ" : "th",
+ "ر" : "r",
+ "ز" : "z",
+ "ژ" : "zh",
+ "س" : "s",
+ "ش" : "sh",
+ "ص" : "s",
+ "ض" : "z",
+ "ط" : "t",
+ "ظ" : "z",
+ "ع" : "a",
+ "غ" : "gh",
+ "ف" : "f",
+ "ق" : "g",
+ "ك" : "k",
+ "ک" : "k",
+ "گ" : "g",
+ "ل" : "l",
+ "م" : "m",
+ "ن" : "n",
+ "و" : "o",
+ "ه" : "h",
+ "ی" : "y"
+}
diff --git a/kirby/i18n/rules/fi.json b/kirby/i18n/rules/fi.json
new file mode 100755
index 0000000..fd35423
--- /dev/null
+++ b/kirby/i18n/rules/fi.json
@@ -0,0 +1,6 @@
+{
+ "Ä": "A",
+ "Ö": "O",
+ "ä": "a",
+ "ö": "o"
+}
diff --git a/kirby/i18n/rules/fr.json b/kirby/i18n/rules/fr.json
new file mode 100755
index 0000000..29c94b9
--- /dev/null
+++ b/kirby/i18n/rules/fr.json
@@ -0,0 +1,34 @@
+{
+ "À": "A",
+ "Â": "A",
+ "Æ": "AE",
+ "Ç": "C",
+ "É": "E",
+ "È": "E",
+ "Ê": "E",
+ "Ë": "E",
+ "Ï": "I",
+ "Î": "I",
+ "Ô": "O",
+ "Œ": "OE",
+ "Ù": "U",
+ "Û": "U",
+ "Ü": "U",
+ "à": "a",
+ "â": "a",
+ "æ": "ae",
+ "ç": "c",
+ "é": "e",
+ "è": "e",
+ "ê": "e",
+ "ë": "e",
+ "ï": "i",
+ "î": "i",
+ "ô": "o",
+ "œ": "oe",
+ "ù": "u",
+ "û": "u",
+ "ü": "u",
+ "ÿ": "y",
+ "Ÿ": "Y"
+}
diff --git a/kirby/i18n/rules/hi.json b/kirby/i18n/rules/hi.json
new file mode 100755
index 0000000..f653f15
--- /dev/null
+++ b/kirby/i18n/rules/hi.json
@@ -0,0 +1,66 @@
+{
+ "अ": "a",
+ "आ": "aa",
+ "ए": "e",
+ "ई": "ii",
+ "ऍ": "ei",
+ "ऎ": "ae",
+ "ऐ": "ai",
+ "इ": "i",
+ "ओ": "o",
+ "ऑ": "oi",
+ "ऒ": "oii",
+ "ऊ": "uu",
+ "औ": "ou",
+ "उ": "u",
+ "ब": "B",
+ "भ": "Bha",
+ "च": "Ca",
+ "छ": "Chha",
+ "ड": "Da",
+ "ढ": "Dha",
+ "फ": "Fa",
+ "फ़": "Fi",
+ "ग": "Ga",
+ "घ": "Gha",
+ "ग़": "Ghi",
+ "ह": "Ha",
+ "ज": "Ja",
+ "झ": "Jha",
+ "क": "Ka",
+ "ख": "Kha",
+ "ख़": "Khi",
+ "ल": "L",
+ "ळ": "Li",
+ "ऌ": "Li",
+ "ऴ": "Lii",
+ "ॡ": "Lii",
+ "म": "Ma",
+ "न": "Na",
+ "ङ": "Na",
+ "ञ": "Nia",
+ "ण": "Nae",
+ "ऩ": "Ni",
+ "ॐ": "oms",
+ "प": "Pa",
+ "क़": "Qi",
+ "र": "Ra",
+ "ऋ": "Ri",
+ "ॠ": "Ri",
+ "ऱ": "Ri",
+ "स": "Sa",
+ "श": "Sha",
+ "ष": "Shha",
+ "ट": "Ta",
+ "त": "Ta",
+ "ठ": "Tha",
+ "द": "Tha",
+ "थ": "Tha",
+ "ध": "Thha",
+ "ड़": "ugDha",
+ "ढ़": "ugDhha",
+ "व": "Va",
+ "य": "Ya",
+ "य़": "Yi",
+ "ज़": "Za"
+}
diff --git a/kirby/i18n/rules/hr.json b/kirby/i18n/rules/hr.json
new file mode 100755
index 0000000..bf2b10d
--- /dev/null
+++ b/kirby/i18n/rules/hr.json
@@ -0,0 +1,12 @@
+{
+ "Č": "C",
+ "Ć": "C",
+ "Ž": "Z",
+ "Š": "S",
+ "Đ": "Dj",
+ "č": "c",
+ "ć": "c",
+ "ž": "z",
+ "š": "s",
+ "đ": "dj"
+}
\ No newline at end of file
diff --git a/kirby/i18n/rules/hu.json b/kirby/i18n/rules/hu.json
new file mode 100755
index 0000000..2bb2f3a
--- /dev/null
+++ b/kirby/i18n/rules/hu.json
@@ -0,0 +1,20 @@
+{
+ "Á": "a",
+ "É": "e",
+ "Í": "i",
+ "Ó": "o",
+ "Ö": "o",
+ "Ő": "o",
+ "Ú": "u",
+ "Ü": "u",
+ "Ű": "u",
+ "á": "a",
+ "é": "e",
+ "í": "i",
+ "ó": "o",
+ "ö": "o",
+ "ő": "o",
+ "ú": "u",
+ "ü": "u",
+ "ű": "u"
+}
diff --git a/kirby/i18n/rules/hy.json b/kirby/i18n/rules/hy.json
new file mode 100755
index 0000000..08188e6
--- /dev/null
+++ b/kirby/i18n/rules/hy.json
@@ -0,0 +1,79 @@
+{
+ "Ա": "A",
+ "Բ": "B",
+ "Գ": "G",
+ "Դ": "D",
+ "Ե": "E",
+ "Զ": "Z",
+ "Է": "E",
+ "Ը": "Y",
+ "Թ": "Th",
+ "Ժ": "Zh",
+ "Ի": "I",
+ "Լ": "L",
+ "Խ": "Kh",
+ "Ծ": "Ts",
+ "Կ": "K",
+ "Հ": "H",
+ "Ձ": "Dz",
+ "Ղ": "Gh",
+ "Ճ": "Tch",
+ "Մ": "M",
+ "Յ": "Y",
+ "Ն": "N",
+ "Շ": "Sh",
+ "Ո": "Vo",
+ "Չ": "Ch",
+ "Պ": "P",
+ "Ջ": "J",
+ "Ռ": "R",
+ "Ս": "S",
+ "Վ": "V",
+ "Տ": "T",
+ "Ր": "R",
+ "Ց": "C",
+ "Ւ": "u",
+ "Փ": "Ph",
+ "Ք": "Q",
+ "և": "ev",
+ "Օ": "O",
+ "Ֆ": "F",
+ "ա": "a",
+ "բ": "b",
+ "գ": "g",
+ "դ": "d",
+ "ե": "e",
+ "զ": "z",
+ "է": "e",
+ "ը": "y",
+ "թ": "th",
+ "ժ": "zh",
+ "ի": "i",
+ "լ": "l",
+ "խ": "kh",
+ "ծ": "ts",
+ "կ": "k",
+ "հ": "h",
+ "ձ": "dz",
+ "ղ": "gh",
+ "ճ": "tch",
+ "մ": "m",
+ "յ": "y",
+ "ն": "n",
+ "շ": "sh",
+ "ո": "vo",
+ "չ": "ch",
+ "պ": "p",
+ "ջ": "j",
+ "ռ": "r",
+ "ս": "s",
+ "վ": "v",
+ "տ": "t",
+ "ր": "r",
+ "ց": "c",
+ "ւ": "u",
+ "փ": "ph",
+ "ք": "q",
+ "օ": "o",
+ "ֆ": "f"
+}
diff --git a/kirby/i18n/rules/it.json b/kirby/i18n/rules/it.json
new file mode 100755
index 0000000..647c2cf
--- /dev/null
+++ b/kirby/i18n/rules/it.json
@@ -0,0 +1,13 @@
+{
+ "À": "a",
+ "È": "e",
+ "Ì": "i",
+ "Ò": "o",
+ "Ù": "u",
+ "à": "a",
+ "é": "e",
+ "è": "e",
+ "ì": "i",
+ "ò": "o",
+ "ù": "u"
+}
diff --git a/kirby/i18n/rules/ja.json b/kirby/i18n/rules/ja.json
new file mode 100755
index 0000000..dd0c615
--- /dev/null
+++ b/kirby/i18n/rules/ja.json
@@ -0,0 +1,166 @@
+{
+ "きゃ": "kya",
+ "しゃ": "sha",
+ "ちゃ": "cha",
+ "にゃ": "nya",
+ "ひゃ": "hya",
+ "みゃ": "mya",
+ "りゃ": "rya",
+ "ぎゃ": "gya",
+ "じゃ": "ja",
+ "ぢゃ": "ja",
+ "びゃ": "bya",
+ "ぴゃ": "pya",
+
+ "きゅ": "kyu",
+ "しゅ": "shu",
+ "ちゅ": "chu",
+ "にゅ": "nyu",
+ "ひゅ": "hyu",
+ "みゅ": "myu",
+ "りゅ": "ryu",
+ "ぎゅ": "gyu",
+ "じゅ": "ju",
+ "ぢゅ": "ju",
+ "びゅ": "byu",
+ "ぴゅ": "pyu",
+
+ "きょ": "kyo",
+ "しょ": "sho",
+ "ちょ": "cho",
+ "にょ": "nyo",
+ "ひょ": "hyo",
+ "みょ": "myo",
+ "りょ": "ryo",
+ "ぎょ": "gyo",
+ "じょ": "jo",
+ "ぢょ": "jo",
+ "びょ": "byo",
+ "ぴょ": "pyo",
+
+ "あ": "a",
+ "ア": "a",
+ "か": "ka",
+ "カ": "ka",
+ "さ": "sa",
+ "サ": "sa",
+ "た": "ta",
+ "タ": "ta",
+ "な": "na",
+ "ナ": "na",
+ "は": "ha",
+ "ハ": "ha",
+ "ま": "ma",
+ "マ": "ma",
+ "や": "ya",
+ "ヤ": "ya",
+ "ら": "ra",
+ "ラ": "ra",
+ "わ": "wa",
+ "ワ": "wa",
+ "が": "ga",
+ "ざ": "za",
+ "だ": "da",
+ "ば": "ba",
+ "ぱ": "pa",
+
+ "い": "i",
+ "イ": "i",
+ "き": "ki",
+ "キ": "ki",
+ "し": "shi",
+ "シ": "shi",
+ "ち": "chi",
+ "チ": "chi",
+ "に": "ni",
+ "ニ": "ni",
+ "ひ": "hi",
+ "ヒ": "hi",
+ "み": "mi",
+ "ミ": "mi",
+ "り": "ri",
+ "リ": "ri",
+ "ゐ": "wi",
+ "ヰ": "wi",
+ "ぎ": "gi",
+ "じ": "dji",
+ "ぢ": "ji",
+ "び": "bi",
+ "ぴ": "pi",
+
+ "う": "u",
+ "ウ": "u",
+ "く": "ku",
+ "ク": "ku",
+ "す": "su",
+ "ス": "su",
+ "つ": "tsu",
+ "ツ": "tsu",
+ "ぬ": "nu",
+ "ヌ": "nu",
+ "ふ": "fu",
+ "フ": "fu",
+ "む": "mu",
+ "ム": "mu",
+ "ゆ": "yu",
+ "ユ": "yu",
+ "る": "ru",
+ "ル": "ru",
+ "ぐ": "gu",
+ "ず": "zu",
+ "づ": "dzu",
+ "ぶ": "bu",
+ "ぷ": "pu",
+
+ "え": "e",
+ "エ": "e",
+ "け": "ke",
+ "ケ": "ke",
+ "せ": "se",
+ "セ": "se",
+ "て": "te",
+ "テ": "te",
+ "ね": "ne",
+ "ネ": "ne",
+ "へ": "he",
+ "ヘ": "he",
+ "め": "me",
+ "メ": "me",
+ "れ": "re",
+ "レ": "re",
+ "ゑ": "we",
+ "ヱ": "we",
+ "げ": "ge",
+ "ぜ": "ze",
+ "で": "de",
+ "べ": "be",
+ "ぺ": "pe",
+
+ "お": "o",
+ "オ": "o",
+ "こ": "ko",
+ "コ": "ko",
+ "そ": "so",
+ "ソ": "so",
+ "と": "to",
+ "ト": "to",
+ "の": "no",
+ "ノ": "no",
+ "ほ": "ho",
+ "ホ": "ho",
+ "も": "mo",
+ "モ": "mo",
+ "よ": "yo",
+ "ヨ": "yo",
+ "ろ": "ro",
+ "ロ": "ro",
+ "を": "wo",
+ "ヲ": "wo",
+ "ん": "n",
+ "ン": "n",
+ "ご": "go",
+ "ぞ": "zo",
+ "ど": "do",
+ "ぼ": "bo",
+ "ぽ": "po"
+}
diff --git a/kirby/i18n/rules/ka.json b/kirby/i18n/rules/ka.json
new file mode 100755
index 0000000..2c63573
--- /dev/null
+++ b/kirby/i18n/rules/ka.json
@@ -0,0 +1,35 @@
+{
+ "ა": "a",
+ "ბ": "b",
+ "გ": "g",
+ "დ": "d",
+ "ე": "e",
+ "ვ": "v",
+ "ზ": "z",
+ "თ": "t",
+ "ი": "i",
+ "კ": "k",
+ "ლ": "l",
+ "მ": "m",
+ "ნ": "n",
+ "ო": "o",
+ "პ": "p",
+ "ჟ": "zh",
+ "რ": "r",
+ "ს": "s",
+ "ტ": "t",
+ "უ": "u",
+ "ფ": "f",
+ "ქ": "k",
+ "ღ": "gh",
+ "ყ": "q",
+ "შ": "sh",
+ "ჩ": "ch",
+ "ც": "ts",
+ "ძ": "dz",
+ "წ": "ts",
+ "ჭ": "ch",
+ "ხ": "kh",
+ "ჯ": "j",
+ "ჰ": "h"
+}
diff --git a/kirby/i18n/rules/lt.json b/kirby/i18n/rules/lt.json
new file mode 100755
index 0000000..23e0d70
--- /dev/null
+++ b/kirby/i18n/rules/lt.json
@@ -0,0 +1,20 @@
+{
+ "Ą": "A",
+ "Č": "C",
+ "Ę": "E",
+ "Ė": "E",
+ "Į": "I",
+ "Š": "S",
+ "Ų": "U",
+ "Ū": "U",
+ "Ž": "Z",
+ "ą": "a",
+ "č": "c",
+ "ę": "e",
+ "ė": "e",
+ "į": "i",
+ "š": "s",
+ "ų": "u",
+ "ū": "u",
+ "ž": "z"
+}
diff --git a/kirby/i18n/rules/lv.json b/kirby/i18n/rules/lv.json
new file mode 100755
index 0000000..d5b0010
--- /dev/null
+++ b/kirby/i18n/rules/lv.json
@@ -0,0 +1,18 @@
+{
+ "Ā": "A",
+ "Ē": "E",
+ "Ģ": "G",
+ "Ī": "I",
+ "Ķ": "K",
+ "Ļ": "L",
+ "Ņ": "N",
+ "Ū": "U",
+ "ā": "a",
+ "ē": "e",
+ "ģ": "g",
+ "ī": "i",
+ "ķ": "k",
+ "ļ": "l",
+ "ņ": "n",
+ "ū": "u"
+}
diff --git a/kirby/i18n/rules/mk.json b/kirby/i18n/rules/mk.json
new file mode 100755
index 0000000..7a87f46
--- /dev/null
+++ b/kirby/i18n/rules/mk.json
@@ -0,0 +1,64 @@
+{
+ "А": "A",
+ "Б": "B",
+ "В": "V",
+ "Г": "G",
+ "Д": "D",
+ "Ѓ": "Gj",
+ "Е": "E",
+ "Ж": "Zh",
+ "З": "Z",
+ "Ѕ": "Dz",
+ "И": "I",
+ "Ј": "J",
+ "К": "K",
+ "Л": "L",
+ "Љ": "Lj",
+ "М": "M",
+ "Н": "N",
+ "Њ": "Nj",
+ "О": "O",
+ "П": "P",
+ "Р": "R",
+ "С": "S",
+ "Т": "T",
+ "Ќ": "Kj",
+ "У": "U",
+ "Ф": "F",
+ "Х": "H",
+ "Ц": "C",
+ "Ч": "Ch",
+ "Џ": "Dj",
+ "Ш": "Sh",
+ "а": "a",
+ "б": "b",
+ "в": "v",
+ "г": "g",
+ "д": "d",
+ "ѓ": "gj",
+ "е": "e",
+ "ж": "zh",
+ "з": "z",
+ "ѕ": "dz",
+ "и": "i",
+ "ј": "j",
+ "к": "k",
+ "л": "l",
+ "љ": "lj",
+ "м": "m",
+ "н": "n",
+ "њ": "nj",
+ "о": "o",
+ "п": "p",
+ "р": "r",
+ "с": "s",
+ "т": "t",
+ "ќ": "kj",
+ "у": "u",
+ "ф": "f",
+ "х": "h",
+ "ц": "c",
+ "ч": "ch",
+ "џ": "dj",
+ "ш": "sh"
+}
diff --git a/kirby/i18n/rules/my.json b/kirby/i18n/rules/my.json
new file mode 100755
index 0000000..08f5a0a
--- /dev/null
+++ b/kirby/i18n/rules/my.json
@@ -0,0 +1,121 @@
+{
+ "က": "k",
+ "ခ": "kh",
+ "ဂ": "g",
+ "ဃ": "ga",
+ "င": "ng",
+ "စ": "s",
+ "ဆ": "sa",
+ "ဇ": "z",
+ "စျ" : "za",
+ "ည": "ny",
+ "ဋ": "t",
+ "ဌ": "ta",
+ "ဍ": "d",
+ "ဎ": "da",
+ "ဏ": "na",
+ "တ": "t",
+ "ထ": "ta",
+ "ဒ": "d",
+ "ဓ": "da",
+ "န": "n",
+ "ပ": "p",
+ "ဖ": "pa",
+ "ဗ": "b",
+ "ဘ": "ba",
+ "မ": "m",
+ "ယ": "y",
+ "ရ": "ya",
+ "လ": "l",
+ "ဝ": "w",
+ "သ": "th",
+ "ဟ": "h",
+ "ဠ": "la",
+ "အ": "a",
+
+ "ြ": "y",
+ "ျ": "ya",
+ "ွ": "w",
+ "ြွ": "yw",
+ "ျွ": "ywa",
+ "ှ": "h",
+
+ "ဧ": "e",
+ "၏": "-e",
+ "ဣ": "i",
+ "ဤ": "-i",
+ "ဉ": "u",
+ "ဦ": "-u",
+ "ဩ": "aw",
+ "သြော" : "aw",
+ "ဪ": "aw",
+ "၍": "ywae",
+ "၌": "hnaik",
+
+ "၀": "0",
+ "၁": "1",
+ "၂": "2",
+ "၃": "3",
+ "၄": "4",
+ "၅": "5",
+ "၆": "6",
+ "၇": "7",
+ "၈": "8",
+ "၉": "9",
+
+ "္": "",
+ "့": "",
+ "း": "",
+
+ "ာ": "a",
+ "ါ": "a",
+ "ေ": "e",
+ "ဲ": "e",
+ "ိ": "i",
+ "ီ": "i",
+ "ို": "o",
+ "ု": "u",
+ "ူ": "u",
+ "ေါင်": "aung",
+ "ော": "aw",
+ "ော်": "aw",
+ "ေါ": "aw",
+ "ေါ်": "aw",
+ "်": "at",
+ "က်": "et",
+ "ိုက်" : "aik",
+ "ောက်" : "auk",
+ "င်" : "in",
+ "ိုင်" : "aing",
+ "ောင်" : "aung",
+ "စ်" : "it",
+ "ည်" : "i",
+ "တ်" : "at",
+ "ိတ်" : "eik",
+ "ုတ်" : "ok",
+ "ွတ်" : "ut",
+ "ေတ်" : "it",
+ "ဒ်" : "d",
+ "ိုဒ်" : "ok",
+ "ုဒ်" : "ait",
+ "န်" : "an",
+ "ာန်" : "an",
+ "ိန်" : "ein",
+ "ုန်" : "on",
+ "ွန်" : "un",
+ "ပ်" : "at",
+ "ိပ်" : "eik",
+ "ုပ်" : "ok",
+ "ွပ်" : "ut",
+ "န်ုပ်" : "nub",
+ "မ်" : "an",
+ "ိမ်" : "ein",
+ "ုမ်" : "on",
+ "ွမ်" : "un",
+ "ယ်" : "e",
+ "ိုလ်" : "ol",
+ "ဉ်" : "in",
+ "ံ": "an",
+ "ိံ" : "ein",
+ "ုံ" : "on"
+}
diff --git a/kirby/i18n/rules/nb.json b/kirby/i18n/rules/nb.json
new file mode 100755
index 0000000..66000ba
--- /dev/null
+++ b/kirby/i18n/rules/nb.json
@@ -0,0 +1,8 @@
+{
+ "Æ": "AE",
+ "Ø": "OE",
+ "Å": "AA",
+ "æ": "ae",
+ "ø": "oe",
+ "å": "aa"
+}
diff --git a/kirby/i18n/rules/pl.json b/kirby/i18n/rules/pl.json
new file mode 100755
index 0000000..5d0c123
--- /dev/null
+++ b/kirby/i18n/rules/pl.json
@@ -0,0 +1,20 @@
+{
+ "Ą": "A",
+ "Ć": "C",
+ "Ę": "E",
+ "Ł": "L",
+ "Ń": "N",
+ "Ó": "O",
+ "Ś": "S",
+ "Ź": "Z",
+ "Ż": "Z",
+ "ą": "a",
+ "ć": "c",
+ "ę": "e",
+ "ł": "l",
+ "ń": "n",
+ "ó": "o",
+ "ś": "s",
+ "ź": "z",
+ "ż": "z"
+}
diff --git a/kirby/i18n/rules/pt_BR.json b/kirby/i18n/rules/pt_BR.json
new file mode 100755
index 0000000..39bca6c
--- /dev/null
+++ b/kirby/i18n/rules/pt_BR.json
@@ -0,0 +1,187 @@
+
+{
+ "°": "0",
+ "¹": "1",
+ "²": "2",
+ "³": "3",
+ "⁴": "4",
+ "⁵": "5",
+ "⁶": "6",
+ "⁷": "7",
+ "⁸": "8",
+ "⁹": "9",
+
+ "₀": "0",
+ "₁": "1",
+ "₂": "2",
+ "₃": "3",
+ "₄": "4",
+ "₅": "5",
+ "₆": "6",
+ "₇": "7",
+ "₈": "8",
+ "₉": "9",
+
+
+ "æ": "ae",
+ "ǽ": "ae",
+ "À": "A",
+ "Á": "A",
+ "Â": "A",
+ "Ã": "A",
+ "Å": "AA",
+ "Ǻ": "A",
+ "Ă": "A",
+ "Ǎ": "A",
+ "Æ": "AE",
+ "Ǽ": "AE",
+ "à": "a",
+ "á": "a",
+ "â": "a",
+ "ã": "a",
+ "å": "aa",
+ "ǻ": "a",
+ "ă": "a",
+ "ǎ": "a",
+ "ª": "a",
+ "@": "at",
+ "Ĉ": "C",
+ "Ċ": "C",
+ "Ç": "Ç",
+ "ç": "ç",
+ "ĉ": "c",
+ "ċ": "c",
+ "©": "c",
+ "Ð": "Dj",
+ "Đ": "D",
+ "ð": "dj",
+ "đ": "d",
+ "È": "E",
+ "É": "E",
+ "Ê": "E",
+ "Ë": "E",
+ "Ĕ": "E",
+ "Ė": "E",
+ "è": "e",
+ "é": "é",
+ "ê": "e",
+ "ë": "e",
+ "ĕ": "e",
+ "ė": "e",
+ "ƒ": "f",
+ "Ĝ": "G",
+ "Ġ": "G",
+ "ĝ": "g",
+ "ġ": "g",
+ "Ĥ": "H",
+ "Ħ": "H",
+ "ĥ": "h",
+ "ħ": "h",
+ "Ì": "I",
+ "Í": "I",
+ "Î": "I",
+ "Ï": "I",
+ "Ĩ": "I",
+ "Ĭ": "I",
+ "Ǐ": "I",
+ "Į": "I",
+ "IJ": "IJ",
+ "ì": "i",
+ "í": "i",
+ "î": "i",
+ "ï": "i",
+ "ĩ": "i",
+ "ĭ": "i",
+ "ǐ": "i",
+ "į": "i",
+ "ij": "ij",
+ "Ĵ": "J",
+ "ĵ": "j",
+ "Ĺ": "L",
+ "Ľ": "L",
+ "Ŀ": "L",
+ "ĺ": "l",
+ "ľ": "l",
+ "ŀ": "l",
+ "Ñ": "N",
+ "ñ": "n",
+ "ʼn": "n",
+ "Ò": "O",
+ "Ó": "O",
+ "Ô": "O",
+ "Õ": "O",
+ "Ō": "O",
+ "Ŏ": "O",
+ "Ǒ": "O",
+ "Ő": "O",
+ "Ơ": "O",
+ "Ø": "OE",
+ "Ǿ": "O",
+ "Œ": "OE",
+ "ò": "o",
+ "ó": "o",
+ "ô": "o",
+ "õ": "o",
+ "ō": "o",
+ "ŏ": "o",
+ "ǒ": "o",
+ "ő": "o",
+ "ơ": "o",
+ "ø": "oe",
+ "ǿ": "o",
+ "º": "o",
+ "œ": "oe",
+ "Ŕ": "R",
+ "Ŗ": "R",
+ "ŕ": "r",
+ "ŗ": "r",
+ "Ŝ": "S",
+ "Ș": "S",
+ "ŝ": "s",
+ "ș": "s",
+ "ſ": "s",
+ "Ţ": "T",
+ "Ț": "T",
+ "Ŧ": "T",
+ "Þ": "TH",
+ "ţ": "t",
+ "ț": "t",
+ "ŧ": "t",
+ "þ": "th",
+ "Ù": "U",
+ "Ú": "U",
+ "Û": "U",
+ "Ü": "U",
+ "Ũ": "U",
+ "Ŭ": "U",
+ "Ű": "U",
+ "Ų": "U",
+ "Ư": "U",
+ "Ǔ": "U",
+ "Ǖ": "U",
+ "Ǘ": "U",
+ "Ǚ": "U",
+ "Ǜ": "U",
+ "ù": "u",
+ "ú": "u",
+ "û": "u",
+ "ü": "u",
+ "ũ": "u",
+ "ŭ": "u",
+ "ű": "u",
+ "ų": "u",
+ "ư": "u",
+ "ǔ": "u",
+ "ǖ": "u",
+ "ǘ": "u",
+ "ǚ": "u",
+ "ǜ": "u",
+ "Ŵ": "W",
+ "ŵ": "w",
+ "Ý": "Y",
+ "Ÿ": "Y",
+ "Ŷ": "Y",
+ "ý": "y",
+ "ÿ": "y",
+ "ŷ": "y"
+}
diff --git a/kirby/i18n/rules/rm.json b/kirby/i18n/rules/rm.json
new file mode 100755
index 0000000..47b9d9b
--- /dev/null
+++ b/kirby/i18n/rules/rm.json
@@ -0,0 +1,16 @@
+{
+ "ă": "a",
+ "î": "i",
+ "â": "a",
+ "ş": "s",
+ "ș": "s",
+ "ţ": "t",
+ "ț": "t",
+ "Ă": "A",
+ "Î": "I",
+ "Â": "A",
+ "Ş": "S",
+ "Ș": "S",
+ "Ţ": "T",
+ "Ț": "T"
+}
diff --git a/kirby/i18n/rules/ru.json b/kirby/i18n/rules/ru.json
new file mode 100755
index 0000000..b8b354c
--- /dev/null
+++ b/kirby/i18n/rules/ru.json
@@ -0,0 +1,68 @@
+{
+ "Ъ": "",
+ "Ь": "",
+ "А": "A",
+ "Б": "B",
+ "Ц": "C",
+ "Ч": "Ch",
+ "Д": "D",
+ "Е": "E",
+ "Ё": "E",
+ "Э": "E",
+ "Ф": "F",
+ "Г": "G",
+ "Х": "H",
+ "И": "I",
+ "Й": "Y",
+ "Я": "Ya",
+ "Ю": "Yu",
+ "К": "K",
+ "Л": "L",
+ "М": "M",
+ "Н": "N",
+ "О": "O",
+ "П": "P",
+ "Р": "R",
+ "С": "S",
+ "Ш": "Sh",
+ "Щ": "Shch",
+ "Т": "T",
+ "У": "U",
+ "В": "V",
+ "Ы": "Y",
+ "З": "Z",
+ "Ж": "Zh",
+ "ъ": "",
+ "ь": "",
+ "а": "a",
+ "б": "b",
+ "ц": "c",
+ "ч": "ch",
+ "д": "d",
+ "е": "e",
+ "ё": "e",
+ "э": "e",
+ "ф": "f",
+ "г": "g",
+ "х": "h",
+ "и": "i",
+ "й": "y",
+ "я": "ya",
+ "ю": "yu",
+ "к": "k",
+ "л": "l",
+ "м": "m",
+ "н": "n",
+ "о": "o",
+ "п": "p",
+ "р": "r",
+ "с": "s",
+ "ш": "sh",
+ "щ": "shch",
+ "т": "t",
+ "у": "u",
+ "в": "v",
+ "ы": "y",
+ "з": "z",
+ "ж": "zh"
+}
diff --git a/kirby/i18n/rules/sr.json b/kirby/i18n/rules/sr.json
new file mode 100755
index 0000000..f4c11db
--- /dev/null
+++ b/kirby/i18n/rules/sr.json
@@ -0,0 +1,72 @@
+{
+ "а": "a",
+ "б": "b",
+ "в": "v",
+ "г": "g",
+ "д": "d",
+ "ђ": "dj",
+ "е": "e",
+ "ж": "z",
+ "з": "z",
+ "и": "i",
+ "ј": "j",
+ "к": "k",
+ "л": "l",
+ "љ": "lj",
+ "м": "m",
+ "н": "n",
+ "њ": "nj",
+ "о": "o",
+ "п": "p",
+ "р": "r",
+ "с": "s",
+ "т": "t",
+ "ћ": "c",
+ "у": "u",
+ "ф": "f",
+ "х": "h",
+ "ц": "c",
+ "ч": "c",
+ "џ": "dz",
+ "ш": "s",
+ "А": "A",
+ "Б": "B",
+ "В": "V",
+ "Г": "G",
+ "Д": "D",
+ "Ђ": "Dj",
+ "Е": "E",
+ "Ж": "Z",
+ "З": "Z",
+ "И": "I",
+ "Ј": "J",
+ "К": "K",
+ "Л": "L",
+ "Љ": "Lj",
+ "М": "M",
+ "Н": "N",
+ "Њ": "Nj",
+ "О": "O",
+ "П": "P",
+ "Р": "R",
+ "С": "S",
+ "Т": "T",
+ "Ћ": "C",
+ "У": "U",
+ "Ф": "F",
+ "Х": "H",
+ "Ц": "C",
+ "Ч": "C",
+ "Џ": "Dz",
+ "Ш": "S",
+ "š": "s",
+ "đ": "dj",
+ "ž": "z",
+ "ć": "c",
+ "č": "c",
+ "Š": "S",
+ "Đ": "DJ",
+ "Ž": "Z",
+ "Ć": "C",
+ "Č": "C"
+}
\ No newline at end of file
diff --git a/kirby/i18n/rules/sv_SE.json b/kirby/i18n/rules/sv_SE.json
new file mode 100755
index 0000000..a22f3eb
--- /dev/null
+++ b/kirby/i18n/rules/sv_SE.json
@@ -0,0 +1,8 @@
+{
+ "Ä": "A",
+ "Å": "a",
+ "Ö": "O",
+ "ä": "a",
+ "å": "a",
+ "ö": "o"
+}
diff --git a/kirby/i18n/rules/tr.json b/kirby/i18n/rules/tr.json
new file mode 100755
index 0000000..07fbae5
--- /dev/null
+++ b/kirby/i18n/rules/tr.json
@@ -0,0 +1,14 @@
+{
+ "Ç": "C",
+ "Ğ": "G",
+ "İ": "I",
+ "Ş": "S",
+ "Ö": "O",
+ "Ü": "U",
+ "ç": "c",
+ "ğ": "g",
+ "ı": "i",
+ "ş": "s",
+ "ö": "o",
+ "ü": "u"
+}
diff --git a/kirby/i18n/rules/uk.json b/kirby/i18n/rules/uk.json
new file mode 100755
index 0000000..673b7ed
--- /dev/null
+++ b/kirby/i18n/rules/uk.json
@@ -0,0 +1,10 @@
+{
+ "Ґ": "G",
+ "І": "I",
+ "Ї": "Ji",
+ "Є": "Ye",
+ "ґ": "g",
+ "і": "i",
+ "ї": "ji",
+ "є": "ye"
+}
diff --git a/kirby/i18n/rules/vi.json b/kirby/i18n/rules/vi.json
new file mode 100755
index 0000000..fdeff69
--- /dev/null
+++ b/kirby/i18n/rules/vi.json
@@ -0,0 +1,135 @@
+{
+ "à": "a",
+ "ạ": "a",
+ "á": "a",
+ "ả": "a",
+ "ã": "a",
+ "â": "a",
+ "ầ": "a",
+ "ấ": "a",
+ "ậ": "a",
+ "ẩ": "a",
+ "ẫ": "a",
+ "ă": "a",
+ "ằ": "a",
+ "ắ": "a",
+ "ặ": "a",
+ "ẳ": "a",
+ "ẵ": "a",
+ "è": "e",
+ "é": "e",
+ "ẹ": "e",
+ "ẻ": "e",
+ "ẽ": "e",
+ "ê": "e",
+ "ề": "e",
+ "ế": "e",
+ "ệ": "e",
+ "ể": "e",
+ "ễ": "e",
+ "ì": "i",
+ "í": "i",
+ "ị": "i",
+ "ỉ": "i",
+ "ĩ": "i",
+ "ò": "o",
+ "ó": "o",
+ "ọ": "o",
+ "ỏ": "o",
+ "õ": "o",
+ "ô": "o",
+ "ồ": "o",
+ "ố": "o",
+ "ộ": "o",
+ "ổ": "o",
+ "ỗ": "o",
+ "ơ": "o",
+ "ờ": "o",
+ "ớ": "o",
+ "ợ": "o",
+ "ở": "o",
+ "ỡ": "o",
+ "ù": "u",
+ "ú": "u",
+ "ụ": "u",
+ "ủ": "u",
+ "ũ": "u",
+ "ư": "u",
+ "ừ": "u",
+ "ứ": "u",
+ "ự": "u",
+ "ử": "u",
+ "ữ": "u",
+ "y": "y",
+ "ỳ": "y",
+ "ý": "y",
+ "ỵ": "y",
+ "ỷ": "y",
+ "ỹ": "y",
+ "À": "A",
+ "Á": "A",
+ "Ạ": "A",
+ "Ả": "A",
+ "Ã": "A",
+ "Â": "A",
+ "Ầ": "A",
+ "Ấ": "A",
+ "Ậ": "A",
+ "Ẩ": "A",
+ "Ẫ": "A",
+ "Ă": "A",
+ "Ằ": "A",
+ "Ắ": "A",
+ "Ặ": "A",
+ "Ẳ": "A",
+ "Ẵ": "A",
+ "È": "E",
+ "É": "E",
+ "Ẹ": "E",
+ "Ẻ": "E",
+ "Ẽ": "E",
+ "Ê": "E",
+ "Ề": "E",
+ "Ế": "E",
+ "Ệ": "E",
+ "Ể": "E",
+ "Ễ": "E",
+ "Ì": "I",
+ "Í": "I",
+ "Ị": "I",
+ "Ỉ": "I",
+ "Ĩ": "I",
+ "Ò": "O",
+ "Ó": "O",
+ "Ọ": "O",
+ "Ỏ": "O",
+ "Õ": "O",
+ "Ô": "O",
+ "Ồ": "O",
+ "Ố": "O",
+ "Ộ": "O",
+ "Ổ": "O",
+ "Ỗ": "O",
+ "Ơ": "O",
+ "Ờ": "O",
+ "Ớ": "O",
+ "Ợ": "O",
+ "Ở": "O",
+ "Ỡ": "O",
+ "Ù": "U",
+ "Ụ": "U",
+ "Ủ": "U",
+ "Ũ": "U",
+ "Ư": "U",
+ "Ừ": "U",
+ "Ứ": "U",
+ "Ự": "U",
+ "Ử": "U",
+ "Ữ": "U",
+ "Y": "Y",
+ "Ỳ": "Y",
+ "Ý": "Y",
+ "Ỵ": "Y",
+ "Ỷ": "Y",
+ "Ỹ": "Y"
+}
diff --git a/kirby/i18n/rules/zh.json b/kirby/i18n/rules/zh.json
new file mode 100755
index 0000000..21ec594
--- /dev/null
+++ b/kirby/i18n/rules/zh.json
@@ -0,0 +1,6937 @@
+{
+ "腌" : "yan",
+ "嗄" : "a",
+ "迫" : "po",
+ "捱" : "ai",
+ "艾" : "ai",
+ "瑷" : "ai",
+ "嗌" : "ai",
+ "犴" : "an",
+ "鳌" : "ao",
+ "廒" : "ao",
+ "拗" : "niu",
+ "岙" : "ao",
+ "鏊" : "ao",
+ "扒" : "ba",
+ "岜" : "ba",
+ "耙" : "pa",
+ "鲅" : "ba",
+ "癍" : "ban",
+ "膀" : "pang",
+ "磅" : "bang",
+ "炮" : "pao",
+ "曝" : "pu",
+ "刨" : "pao",
+ "瀑" : "pu",
+ "陂" : "bei",
+ "埤" : "pi",
+ "鹎" : "bei",
+ "邶" : "bei",
+ "孛" : "bei",
+ "鐾" : "bei",
+ "鞴" : "bei",
+ "畚" : "ben",
+ "甏" : "beng",
+ "舭" : "bi",
+ "秘" : "mi",
+ "辟" : "pi",
+ "泌" : "mi",
+ "裨" : "bi",
+ "濞" : "bi",
+ "庳" : "bi",
+ "嬖" : "bi",
+ "畀" : "bi",
+ "筚" : "bi",
+ "箅" : "bi",
+ "襞" : "bi",
+ "跸" : "bi",
+ "笾" : "bian",
+ "扁" : "bian",
+ "碥" : "bian",
+ "窆" : "bian",
+ "便" : "bian",
+ "弁" : "bian",
+ "缏" : "bian",
+ "骠" : "biao",
+ "杓" : "shao",
+ "飚" : "biao",
+ "飑" : "biao",
+ "瘭" : "biao",
+ "髟" : "biao",
+ "玢" : "bin",
+ "豳" : "bin",
+ "镔" : "bin",
+ "膑" : "bin",
+ "屏" : "ping",
+ "泊" : "bo",
+ "逋" : "bu",
+ "晡" : "bu",
+ "钸" : "bu",
+ "醭" : "bu",
+ "埔" : "pu",
+ "瓿" : "bu",
+ "礤" : "ca",
+ "骖" : "can",
+ "藏" : "cang",
+ "艚" : "cao",
+ "侧" : "ce",
+ "喳" : "zha",
+ "刹" : "sha",
+ "瘥" : "chai",
+ "禅" : "chan",
+ "廛" : "chan",
+ "镡" : "tan",
+ "澶" : "chan",
+ "躔" : "chan",
+ "阊" : "chang",
+ "鲳" : "chang",
+ "长" : "chang",
+ "苌" : "chang",
+ "氅" : "chang",
+ "鬯" : "chang",
+ "焯" : "chao",
+ "朝" : "chao",
+ "车" : "che",
+ "琛" : "chen",
+ "谶" : "chen",
+ "榇" : "chen",
+ "蛏" : "cheng",
+ "埕" : "cheng",
+ "枨" : "cheng",
+ "塍" : "cheng",
+ "裎" : "cheng",
+ "螭" : "chi",
+ "眵" : "chi",
+ "墀" : "chi",
+ "篪" : "chi",
+ "坻" : "di",
+ "瘛" : "chi",
+ "种" : "zhong",
+ "重" : "zhong",
+ "仇" : "chou",
+ "帱" : "chou",
+ "俦" : "chou",
+ "雠" : "chou",
+ "臭" : "chou",
+ "楮" : "chu",
+ "畜" : "chu",
+ "嘬" : "zuo",
+ "膪" : "chuai",
+ "巛" : "chuan",
+ "椎" : "zhui",
+ "呲" : "ci",
+ "兹" : "zi",
+ "伺" : "si",
+ "璁" : "cong",
+ "楱" : "cou",
+ "攒" : "zan",
+ "爨" : "cuan",
+ "隹" : "zhui",
+ "榱" : "cui",
+ "撮" : "cuo",
+ "鹾" : "cuo",
+ "嗒" : "da",
+ "哒" : "da",
+ "沓" : "ta",
+ "骀" : "tai",
+ "绐" : "dai",
+ "埭" : "dai",
+ "甙" : "dai",
+ "弹" : "dan",
+ "澹" : "dan",
+ "叨" : "dao",
+ "纛" : "dao",
+ "簦" : "deng",
+ "提" : "ti",
+ "翟" : "zhai",
+ "绨" : "ti",
+ "丶" : "dian",
+ "佃" : "dian",
+ "簟" : "dian",
+ "癜" : "dian",
+ "调" : "tiao",
+ "铞" : "diao",
+ "佚" : "yi",
+ "堞" : "die",
+ "瓞" : "die",
+ "揲" : "die",
+ "垤" : "die",
+ "疔" : "ding",
+ "岽" : "dong",
+ "硐" : "dong",
+ "恫" : "dong",
+ "垌" : "dong",
+ "峒" : "dong",
+ "芏" : "du",
+ "煅" : "duan",
+ "碓" : "dui",
+ "镦" : "dui",
+ "囤" : "tun",
+ "铎" : "duo",
+ "缍" : "duo",
+ "驮" : "tuo",
+ "沲" : "tuo",
+ "柁" : "tuo",
+ "哦" : "o",
+ "恶" : "e",
+ "轭" : "e",
+ "锷" : "e",
+ "鹗" : "e",
+ "阏" : "e",
+ "诶" : "ea",
+ "鲕" : "er",
+ "珥" : "er",
+ "佴" : "er",
+ "番" : "fan",
+ "彷" : "pang",
+ "霏" : "fei",
+ "蜚" : "fei",
+ "鲱" : "fei",
+ "芾" : "fei",
+ "瀵" : "fen",
+ "鲼" : "fen",
+ "否" : "fou",
+ "趺" : "fu",
+ "桴" : "fu",
+ "莩" : "fu",
+ "菔" : "fu",
+ "幞" : "fu",
+ "郛" : "fu",
+ "绂" : "fu",
+ "绋" : "fu",
+ "祓" : "fu",
+ "砩" : "fu",
+ "黻" : "fu",
+ "罘" : "fu",
+ "蚨" : "fu",
+ "脯" : "pu",
+ "滏" : "fu",
+ "黼" : "fu",
+ "鲋" : "fu",
+ "鳆" : "fu",
+ "咖" : "ka",
+ "噶" : "ga",
+ "轧" : "zha",
+ "陔" : "gai",
+ "戤" : "gai",
+ "扛" : "kang",
+ "戆" : "gang",
+ "筻" : "gang",
+ "槔" : "gao",
+ "藁" : "gao",
+ "缟" : "gao",
+ "咯" : "ge",
+ "仡" : "yi",
+ "搿" : "ge",
+ "塥" : "ge",
+ "鬲" : "ge",
+ "哿" : "ge",
+ "句" : "ju",
+ "缑" : "gou",
+ "鞲" : "gou",
+ "笱" : "gou",
+ "遘" : "gou",
+ "瞽" : "gu",
+ "罟" : "gu",
+ "嘏" : "gu",
+ "牿" : "gu",
+ "鲴" : "gu",
+ "栝" : "kuo",
+ "莞" : "guan",
+ "纶" : "lun",
+ "涫" : "guan",
+ "涡" : "wo",
+ "呙" : "guo",
+ "馘" : "guo",
+ "猓" : "guo",
+ "咳" : "ke",
+ "氦" : "hai",
+ "颔" : "han",
+ "吭" : "keng",
+ "颃" : "hang",
+ "巷" : "xiang",
+ "蚵" : "ke",
+ "翮" : "he",
+ "吓" : "xia",
+ "桁" : "heng",
+ "泓" : "hong",
+ "蕻" : "hong",
+ "黉" : "hong",
+ "後" : "hou",
+ "唿" : "hu",
+ "煳" : "hu",
+ "浒" : "hu",
+ "祜" : "hu",
+ "岵" : "hu",
+ "鬟" : "huan",
+ "圜" : "huan",
+ "郇" : "xun",
+ "锾" : "huan",
+ "逭" : "huan",
+ "咴" : "hui",
+ "虺" : "hui",
+ "会" : "hui",
+ "溃" : "kui",
+ "哕" : "hui",
+ "缋" : "hui",
+ "锪" : "huo",
+ "蠖" : "huo",
+ "缉" : "ji",
+ "稽" : "ji",
+ "赍" : "ji",
+ "丌" : "ji",
+ "咭" : "ji",
+ "亟" : "ji",
+ "殛" : "ji",
+ "戢" : "ji",
+ "嵴" : "ji",
+ "蕺" : "ji",
+ "系" : "xi",
+ "蓟" : "ji",
+ "霁" : "ji",
+ "荠" : "qi",
+ "跽" : "ji",
+ "哜" : "ji",
+ "鲚" : "ji",
+ "洎" : "ji",
+ "芰" : "ji",
+ "茄" : "qie",
+ "珈" : "jia",
+ "迦" : "jia",
+ "笳" : "jia",
+ "葭" : "jia",
+ "跏" : "jia",
+ "郏" : "jia",
+ "恝" : "jia",
+ "铗" : "jia",
+ "袷" : "qia",
+ "蛱" : "jia",
+ "角" : "jiao",
+ "挢" : "jiao",
+ "岬" : "jia",
+ "徼" : "jiao",
+ "湫" : "qiu",
+ "敫" : "jiao",
+ "瘕" : "jia",
+ "浅" : "qian",
+ "蒹" : "jian",
+ "搛" : "jian",
+ "湔" : "jian",
+ "缣" : "jian",
+ "犍" : "jian",
+ "鹣" : "jian",
+ "鲣" : "jian",
+ "鞯" : "jian",
+ "蹇" : "jian",
+ "謇" : "jian",
+ "硷" : "jian",
+ "枧" : "jian",
+ "戬" : "jian",
+ "谫" : "jian",
+ "囝" : "jian",
+ "裥" : "jian",
+ "笕" : "jian",
+ "翦" : "jian",
+ "趼" : "jian",
+ "楗" : "jian",
+ "牮" : "jian",
+ "踺" : "jian",
+ "茳" : "jiang",
+ "礓" : "jiang",
+ "耩" : "jiang",
+ "降" : "jiang",
+ "绛" : "jiang",
+ "洚" : "jiang",
+ "鲛" : "jiao",
+ "僬" : "jiao",
+ "鹪" : "jiao",
+ "艽" : "jiao",
+ "茭" : "jiao",
+ "嚼" : "jiao",
+ "峤" : "qiao",
+ "觉" : "jiao",
+ "校" : "xiao",
+ "噍" : "jiao",
+ "醮" : "jiao",
+ "疖" : "jie",
+ "喈" : "jie",
+ "桔" : "ju",
+ "拮" : "jie",
+ "桀" : "jie",
+ "颉" : "jie",
+ "婕" : "jie",
+ "羯" : "jie",
+ "鲒" : "jie",
+ "蚧" : "jie",
+ "骱" : "jie",
+ "衿" : "jin",
+ "馑" : "jin",
+ "卺" : "jin",
+ "廑" : "jin",
+ "堇" : "jin",
+ "槿" : "jin",
+ "靳" : "jin",
+ "缙" : "jin",
+ "荩" : "jin",
+ "赆" : "jin",
+ "妗" : "jin",
+ "旌" : "jing",
+ "腈" : "jing",
+ "憬" : "jing",
+ "肼" : "jing",
+ "迳" : "jing",
+ "胫" : "jing",
+ "弪" : "jing",
+ "獍" : "jing",
+ "扃" : "jiong",
+ "鬏" : "jiu",
+ "疚" : "jiu",
+ "僦" : "jiu",
+ "桕" : "jiu",
+ "疽" : "ju",
+ "裾" : "ju",
+ "苴" : "ju",
+ "椐" : "ju",
+ "锔" : "ju",
+ "琚" : "ju",
+ "鞫" : "ju",
+ "踽" : "ju",
+ "榉" : "ju",
+ "莒" : "ju",
+ "遽" : "ju",
+ "倨" : "ju",
+ "钜" : "ju",
+ "犋" : "ju",
+ "屦" : "ju",
+ "榘" : "ju",
+ "窭" : "ju",
+ "讵" : "ju",
+ "醵" : "ju",
+ "苣" : "ju",
+ "圈" : "quan",
+ "镌" : "juan",
+ "蠲" : "juan",
+ "锩" : "juan",
+ "狷" : "juan",
+ "桊" : "juan",
+ "鄄" : "juan",
+ "獗" : "jue",
+ "攫" : "jue",
+ "孓" : "jue",
+ "橛" : "jue",
+ "珏" : "jue",
+ "桷" : "jue",
+ "劂" : "jue",
+ "爝" : "jue",
+ "镢" : "jue",
+ "觖" : "jue",
+ "筠" : "jun",
+ "麇" : "jun",
+ "捃" : "jun",
+ "浚" : "jun",
+ "喀" : "ka",
+ "卡" : "ka",
+ "佧" : "ka",
+ "胩" : "ka",
+ "锎" : "kai",
+ "蒈" : "kai",
+ "剀" : "kai",
+ "垲" : "kai",
+ "锴" : "kai",
+ "戡" : "kan",
+ "莰" : "kan",
+ "闶" : "kang",
+ "钪" : "kang",
+ "尻" : "kao",
+ "栲" : "kao",
+ "柯" : "ke",
+ "疴" : "ke",
+ "钶" : "ke",
+ "颏" : "ke",
+ "珂" : "ke",
+ "髁" : "ke",
+ "壳" : "ke",
+ "岢" : "ke",
+ "溘" : "ke",
+ "骒" : "ke",
+ "缂" : "ke",
+ "氪" : "ke",
+ "锞" : "ke",
+ "裉" : "ken",
+ "倥" : "kong",
+ "崆" : "kong",
+ "箜" : "kong",
+ "芤" : "kou",
+ "眍" : "kou",
+ "筘" : "kou",
+ "刳" : "ku",
+ "堀" : "ku",
+ "喾" : "ku",
+ "侉" : "kua",
+ "蒯" : "kuai",
+ "哙" : "kuai",
+ "狯" : "kuai",
+ "郐" : "kuai",
+ "匡" : "kuang",
+ "夼" : "kuang",
+ "邝" : "kuang",
+ "圹" : "kuang",
+ "纩" : "kuang",
+ "贶" : "kuang",
+ "岿" : "kui",
+ "悝" : "kui",
+ "睽" : "kui",
+ "逵" : "kui",
+ "馗" : "kui",
+ "夔" : "kui",
+ "喹" : "kui",
+ "隗" : "wei",
+ "暌" : "kui",
+ "揆" : "kui",
+ "蝰" : "kui",
+ "跬" : "kui",
+ "喟" : "kui",
+ "聩" : "kui",
+ "篑" : "kui",
+ "蒉" : "kui",
+ "愦" : "kui",
+ "锟" : "kun",
+ "醌" : "kun",
+ "琨" : "kun",
+ "髡" : "kun",
+ "悃" : "kun",
+ "阃" : "kun",
+ "蛞" : "kuo",
+ "砬" : "la",
+ "落" : "luo",
+ "剌" : "la",
+ "瘌" : "la",
+ "涞" : "lai",
+ "崃" : "lai",
+ "铼" : "lai",
+ "赉" : "lai",
+ "濑" : "lai",
+ "斓" : "lan",
+ "镧" : "lan",
+ "谰" : "lan",
+ "漤" : "lan",
+ "罱" : "lan",
+ "稂" : "lang",
+ "阆" : "lang",
+ "莨" : "liang",
+ "蒗" : "lang",
+ "铹" : "lao",
+ "痨" : "lao",
+ "醪" : "lao",
+ "栳" : "lao",
+ "铑" : "lao",
+ "耢" : "lao",
+ "勒" : "le",
+ "仂" : "le",
+ "叻" : "le",
+ "泐" : "le",
+ "鳓" : "le",
+ "了" : "le",
+ "镭" : "lei",
+ "嫘" : "lei",
+ "缧" : "lei",
+ "檑" : "lei",
+ "诔" : "lei",
+ "耒" : "lei",
+ "酹" : "lei",
+ "塄" : "leng",
+ "愣" : "leng",
+ "藜" : "li",
+ "骊" : "li",
+ "黧" : "li",
+ "缡" : "li",
+ "嫠" : "li",
+ "鲡" : "li",
+ "蓠" : "li",
+ "澧" : "li",
+ "锂" : "li",
+ "醴" : "li",
+ "鳢" : "li",
+ "俪" : "li",
+ "砺" : "li",
+ "郦" : "li",
+ "詈" : "li",
+ "猁" : "li",
+ "溧" : "li",
+ "栎" : "li",
+ "轹" : "li",
+ "傈" : "li",
+ "坜" : "li",
+ "苈" : "li",
+ "疠" : "li",
+ "疬" : "li",
+ "篥" : "li",
+ "粝" : "li",
+ "跞" : "li",
+ "俩" : "liang",
+ "裢" : "lian",
+ "濂" : "lian",
+ "臁" : "lian",
+ "奁" : "lian",
+ "蠊" : "lian",
+ "琏" : "lian",
+ "蔹" : "lian",
+ "裣" : "lian",
+ "楝" : "lian",
+ "潋" : "lian",
+ "椋" : "liang",
+ "墚" : "liang",
+ "寮" : "liao",
+ "鹩" : "liao",
+ "蓼" : "liao",
+ "钌" : "liao",
+ "廖" : "liao",
+ "尥" : "liao",
+ "洌" : "lie",
+ "捩" : "lie",
+ "埒" : "lie",
+ "躐" : "lie",
+ "鬣" : "lie",
+ "辚" : "lin",
+ "遴" : "lin",
+ "啉" : "lin",
+ "瞵" : "lin",
+ "懔" : "lin",
+ "廪" : "lin",
+ "蔺" : "lin",
+ "膦" : "lin",
+ "酃" : "ling",
+ "柃" : "ling",
+ "鲮" : "ling",
+ "呤" : "ling",
+ "镏" : "liu",
+ "旒" : "liu",
+ "骝" : "liu",
+ "鎏" : "liu",
+ "锍" : "liu",
+ "碌" : "lu",
+ "鹨" : "liu",
+ "茏" : "long",
+ "栊" : "long",
+ "泷" : "long",
+ "砻" : "long",
+ "癃" : "long",
+ "垅" : "long",
+ "偻" : "lou",
+ "蝼" : "lou",
+ "蒌" : "lou",
+ "耧" : "lou",
+ "嵝" : "lou",
+ "露" : "lu",
+ "瘘" : "lou",
+ "噜" : "lu",
+ "轳" : "lu",
+ "垆" : "lu",
+ "胪" : "lu",
+ "舻" : "lu",
+ "栌" : "lu",
+ "镥" : "lu",
+ "绿" : "lv",
+ "辘" : "lu",
+ "簏" : "lu",
+ "潞" : "lu",
+ "辂" : "lu",
+ "渌" : "lu",
+ "氇" : "lu",
+ "捋" : "lv",
+ "稆" : "lv",
+ "率" : "lv",
+ "闾" : "lv",
+ "栾" : "luan",
+ "銮" : "luan",
+ "滦" : "luan",
+ "娈" : "luan",
+ "脔" : "luan",
+ "锊" : "lve",
+ "猡" : "luo",
+ "椤" : "luo",
+ "脶" : "luo",
+ "镙" : "luo",
+ "倮" : "luo",
+ "蠃" : "luo",
+ "瘰" : "luo",
+ "珞" : "luo",
+ "泺" : "luo",
+ "荦" : "luo",
+ "雒" : "luo",
+ "呒" : "mu",
+ "抹" : "mo",
+ "唛" : "mai",
+ "杩" : "ma",
+ "么" : "me",
+ "埋" : "mai",
+ "荬" : "mai",
+ "脉" : "mai",
+ "劢" : "mai",
+ "颟" : "man",
+ "蔓" : "man",
+ "鳗" : "man",
+ "鞔" : "man",
+ "螨" : "man",
+ "墁" : "man",
+ "缦" : "man",
+ "熳" : "man",
+ "镘" : "man",
+ "邙" : "mang",
+ "硭" : "mang",
+ "旄" : "mao",
+ "茆" : "mao",
+ "峁" : "mao",
+ "泖" : "mao",
+ "昴" : "mao",
+ "耄" : "mao",
+ "瑁" : "mao",
+ "懋" : "mao",
+ "瞀" : "mao",
+ "麽" : "me",
+ "没" : "mei",
+ "嵋" : "mei",
+ "湄" : "mei",
+ "猸" : "mei",
+ "镅" : "mei",
+ "鹛" : "mei",
+ "浼" : "mei",
+ "钔" : "men",
+ "瞢" : "meng",
+ "甍" : "meng",
+ "礞" : "meng",
+ "艨" : "meng",
+ "黾" : "mian",
+ "鳘" : "min",
+ "溟" : "ming",
+ "暝" : "ming",
+ "模" : "mo",
+ "谟" : "mo",
+ "嫫" : "mo",
+ "镆" : "mo",
+ "瘼" : "mo",
+ "耱" : "mo",
+ "貊" : "mo",
+ "貘" : "mo",
+ "牟" : "mou",
+ "鍪" : "mou",
+ "蛑" : "mou",
+ "侔" : "mou",
+ "毪" : "mu",
+ "坶" : "mu",
+ "仫" : "mu",
+ "唔" : "wu",
+ "那" : "na",
+ "镎" : "na",
+ "哪" : "na",
+ "呢" : "ne",
+ "肭" : "na",
+ "艿" : "nai",
+ "鼐" : "nai",
+ "萘" : "nai",
+ "柰" : "nai",
+ "蝻" : "nan",
+ "馕" : "nang",
+ "攮" : "nang",
+ "曩" : "nang",
+ "猱" : "nao",
+ "铙" : "nao",
+ "硇" : "nao",
+ "蛲" : "nao",
+ "垴" : "nao",
+ "坭" : "ni",
+ "猊" : "ni",
+ "铌" : "ni",
+ "鲵" : "ni",
+ "祢" : "mi",
+ "睨" : "ni",
+ "慝" : "te",
+ "伲" : "ni",
+ "鲇" : "nian",
+ "鲶" : "nian",
+ "埝" : "nian",
+ "嬲" : "niao",
+ "茑" : "niao",
+ "脲" : "niao",
+ "啮" : "nie",
+ "陧" : "nie",
+ "颞" : "nie",
+ "臬" : "nie",
+ "蘖" : "nie",
+ "甯" : "ning",
+ "聍" : "ning",
+ "狃" : "niu",
+ "侬" : "nong",
+ "耨" : "nou",
+ "孥" : "nu",
+ "胬" : "nu",
+ "钕" : "nv",
+ "恧" : "nv",
+ "褰" : "qian",
+ "掮" : "qian",
+ "荨" : "xun",
+ "钤" : "qian",
+ "箝" : "qian",
+ "鬈" : "quan",
+ "缱" : "qian",
+ "肷" : "qian",
+ "纤" : "xian",
+ "茜" : "qian",
+ "慊" : "qian",
+ "椠" : "qian",
+ "戗" : "qiang",
+ "镪" : "qiang",
+ "锖" : "qiang",
+ "樯" : "qiang",
+ "嫱" : "qiang",
+ "雀" : "que",
+ "缲" : "qiao",
+ "硗" : "qiao",
+ "劁" : "qiao",
+ "樵" : "qiao",
+ "谯" : "qiao",
+ "鞒" : "qiao",
+ "愀" : "qiao",
+ "鞘" : "qiao",
+ "郄" : "xi",
+ "箧" : "qie",
+ "亲" : "qin",
+ "覃" : "tan",
+ "溱" : "qin",
+ "檎" : "qin",
+ "锓" : "qin",
+ "嗪" : "qin",
+ "螓" : "qin",
+ "揿" : "qin",
+ "吣" : "qin",
+ "圊" : "qing",
+ "鲭" : "qing",
+ "檠" : "qing",
+ "黥" : "qing",
+ "謦" : "qing",
+ "苘" : "qing",
+ "磬" : "qing",
+ "箐" : "qing",
+ "綮" : "qi",
+ "茕" : "qiong",
+ "邛" : "dao",
+ "蛩" : "tun",
+ "筇" : "qiong",
+ "跫" : "qiong",
+ "銎" : "qiong",
+ "楸" : "qiu",
+ "俅" : "qiu",
+ "赇" : "qiu",
+ "逑" : "qiu",
+ "犰" : "qiu",
+ "蝤" : "qiu",
+ "巯" : "qiu",
+ "鼽" : "qiu",
+ "糗" : "qiu",
+ "区" : "qu",
+ "祛" : "qu",
+ "麴" : "qu",
+ "诎" : "qu",
+ "衢" : "qu",
+ "癯" : "qu",
+ "劬" : "qu",
+ "璩" : "qu",
+ "氍" : "qu",
+ "朐" : "qu",
+ "磲" : "qu",
+ "鸲" : "qu",
+ "蕖" : "qu",
+ "蠼" : "qu",
+ "蘧" : "qu",
+ "阒" : "qu",
+ "颧" : "quan",
+ "荃" : "quan",
+ "铨" : "quan",
+ "辁" : "quan",
+ "筌" : "quan",
+ "绻" : "quan",
+ "畎" : "quan",
+ "阕" : "que",
+ "悫" : "que",
+ "髯" : "ran",
+ "禳" : "rang",
+ "穰" : "rang",
+ "仞" : "ren",
+ "妊" : "ren",
+ "轫" : "ren",
+ "衽" : "ren",
+ "狨" : "rong",
+ "肜" : "rong",
+ "蝾" : "rong",
+ "嚅" : "ru",
+ "濡" : "ru",
+ "薷" : "ru",
+ "襦" : "ru",
+ "颥" : "ru",
+ "洳" : "ru",
+ "溽" : "ru",
+ "蓐" : "ru",
+ "朊" : "ruan",
+ "蕤" : "rui",
+ "枘" : "rui",
+ "箬" : "ruo",
+ "挲" : "suo",
+ "脎" : "sa",
+ "塞" : "sai",
+ "鳃" : "sai",
+ "噻" : "sai",
+ "毵" : "san",
+ "馓" : "san",
+ "糁" : "san",
+ "霰" : "xian",
+ "磉" : "sang",
+ "颡" : "sang",
+ "缫" : "sao",
+ "鳋" : "sao",
+ "埽" : "sao",
+ "瘙" : "sao",
+ "色" : "se",
+ "杉" : "shan",
+ "鲨" : "sha",
+ "痧" : "sha",
+ "裟" : "sha",
+ "铩" : "sha",
+ "唼" : "sha",
+ "酾" : "shai",
+ "栅" : "zha",
+ "跚" : "shan",
+ "芟" : "shan",
+ "埏" : "shan",
+ "钐" : "shan",
+ "舢" : "shan",
+ "剡" : "yan",
+ "鄯" : "shan",
+ "疝" : "shan",
+ "蟮" : "shan",
+ "墒" : "shang",
+ "垧" : "shang",
+ "绱" : "shang",
+ "蛸" : "shao",
+ "筲" : "shao",
+ "苕" : "tiao",
+ "召" : "zhao",
+ "劭" : "shao",
+ "猞" : "she",
+ "畲" : "she",
+ "折" : "zhe",
+ "滠" : "she",
+ "歙" : "xi",
+ "厍" : "she",
+ "莘" : "shen",
+ "娠" : "shen",
+ "诜" : "shen",
+ "什" : "shen",
+ "谂" : "shen",
+ "渖" : "shen",
+ "矧" : "shen",
+ "胂" : "shen",
+ "椹" : "shen",
+ "省" : "sheng",
+ "眚" : "sheng",
+ "嵊" : "sheng",
+ "嘘" : "xu",
+ "蓍" : "shi",
+ "鲺" : "shi",
+ "识" : "shi",
+ "拾" : "shi",
+ "埘" : "shi",
+ "莳" : "shi",
+ "炻" : "shi",
+ "鲥" : "shi",
+ "豕" : "shi",
+ "似" : "si",
+ "噬" : "shi",
+ "贳" : "shi",
+ "铈" : "shi",
+ "螫" : "shi",
+ "筮" : "shi",
+ "殖" : "zhi",
+ "熟" : "shu",
+ "艏" : "shou",
+ "菽" : "shu",
+ "摅" : "shu",
+ "纾" : "shu",
+ "毹" : "shu",
+ "疋" : "shu",
+ "数" : "shu",
+ "属" : "shu",
+ "术" : "shu",
+ "澍" : "shu",
+ "沭" : "shu",
+ "丨" : "shu",
+ "腧" : "shu",
+ "说" : "shuo",
+ "妁" : "shuo",
+ "蒴" : "shuo",
+ "槊" : "shuo",
+ "搠" : "shuo",
+ "鸶" : "si",
+ "澌" : "si",
+ "缌" : "si",
+ "锶" : "si",
+ "厶" : "si",
+ "蛳" : "si",
+ "驷" : "si",
+ "泗" : "si",
+ "汜" : "si",
+ "兕" : "si",
+ "姒" : "si",
+ "耜" : "si",
+ "笥" : "si",
+ "忪" : "song",
+ "淞" : "song",
+ "崧" : "song",
+ "凇" : "song",
+ "菘" : "song",
+ "竦" : "song",
+ "溲" : "sou",
+ "飕" : "sou",
+ "蜩" : "tiao",
+ "萜" : "tie",
+ "汀" : "ting",
+ "葶" : "ting",
+ "莛" : "ting",
+ "梃" : "ting",
+ "佟" : "tong",
+ "酮" : "tong",
+ "仝" : "tong",
+ "茼" : "tong",
+ "砼" : "tong",
+ "钭" : "dou",
+ "酴" : "tu",
+ "钍" : "tu",
+ "堍" : "tu",
+ "抟" : "tuan",
+ "忒" : "te",
+ "煺" : "tui",
+ "暾" : "tun",
+ "氽" : "tun",
+ "乇" : "tuo",
+ "砣" : "tuo",
+ "沱" : "tuo",
+ "跎" : "tuo",
+ "坨" : "tuo",
+ "橐" : "tuo",
+ "酡" : "tuo",
+ "鼍" : "tuo",
+ "庹" : "tuo",
+ "拓" : "tuo",
+ "柝" : "tuo",
+ "箨" : "tuo",
+ "腽" : "wa",
+ "崴" : "wai",
+ "芄" : "wan",
+ "畹" : "wan",
+ "琬" : "wan",
+ "脘" : "wan",
+ "菀" : "wan",
+ "尢" : "you",
+ "辋" : "wang",
+ "魍" : "wang",
+ "逶" : "wei",
+ "葳" : "wei",
+ "隈" : "wei",
+ "惟" : "wei",
+ "帏" : "wei",
+ "圩" : "wei",
+ "囗" : "wei",
+ "潍" : "wei",
+ "嵬" : "wei",
+ "沩" : "wei",
+ "涠" : "wei",
+ "尾" : "wei",
+ "玮" : "wei",
+ "炜" : "wei",
+ "韪" : "wei",
+ "洧" : "wei",
+ "艉" : "wei",
+ "鲔" : "wei",
+ "遗" : "yi",
+ "尉" : "wei",
+ "軎" : "wei",
+ "璺" : "wen",
+ "阌" : "wen",
+ "蓊" : "weng",
+ "蕹" : "weng",
+ "渥" : "wo",
+ "硪" : "wo",
+ "龌" : "wo",
+ "圬" : "wu",
+ "吾" : "wu",
+ "浯" : "wu",
+ "鼯" : "wu",
+ "牾" : "wu",
+ "迕" : "wu",
+ "庑" : "wu",
+ "痦" : "wu",
+ "芴" : "wu",
+ "杌" : "wu",
+ "焐" : "wu",
+ "阢" : "wu",
+ "婺" : "wu",
+ "鋈" : "wu",
+ "樨" : "xi",
+ "栖" : "qi",
+ "郗" : "xi",
+ "蹊" : "qi",
+ "淅" : "xi",
+ "熹" : "xi",
+ "浠" : "xi",
+ "僖" : "xi",
+ "穸" : "xi",
+ "螅" : "xi",
+ "菥" : "xi",
+ "舾" : "xi",
+ "矽" : "xi",
+ "粞" : "xi",
+ "硒" : "xi",
+ "醯" : "xi",
+ "欷" : "xi",
+ "鼷" : "xi",
+ "檄" : "xi",
+ "隰" : "xi",
+ "觋" : "xi",
+ "屣" : "xi",
+ "葸" : "xi",
+ "蓰" : "xi",
+ "铣" : "xi",
+ "饩" : "xi",
+ "阋" : "xi",
+ "禊" : "xi",
+ "舄" : "xi",
+ "狎" : "xia",
+ "硖" : "xia",
+ "柙" : "xia",
+ "暹" : "xian",
+ "莶" : "xian",
+ "祆" : "xian",
+ "籼" : "xian",
+ "跹" : "xian",
+ "鹇" : "xian",
+ "痫" : "xian",
+ "猃" : "xian",
+ "燹" : "xian",
+ "蚬" : "xian",
+ "筅" : "xian",
+ "冼" : "xian",
+ "岘" : "xian",
+ "骧" : "xiang",
+ "葙" : "xiang",
+ "芗" : "xiang",
+ "缃" : "xiang",
+ "庠" : "xiang",
+ "鲞" : "xiang",
+ "蟓" : "xiang",
+ "削" : "xue",
+ "枵" : "xiao",
+ "绡" : "xiao",
+ "筱" : "xiao",
+ "邪" : "xie",
+ "勰" : "xie",
+ "缬" : "xie",
+ "血" : "xue",
+ "榭" : "xie",
+ "瀣" : "xie",
+ "薤" : "xie",
+ "燮" : "xie",
+ "躞" : "xie",
+ "廨" : "xie",
+ "绁" : "xie",
+ "渫" : "xie",
+ "榍" : "xie",
+ "獬" : "xie",
+ "昕" : "xin",
+ "忻" : "xin",
+ "囟" : "xin",
+ "陉" : "jing",
+ "荥" : "ying",
+ "饧" : "tang",
+ "硎" : "xing",
+ "荇" : "xing",
+ "芎" : "xiong",
+ "馐" : "xiu",
+ "庥" : "xiu",
+ "鸺" : "xiu",
+ "貅" : "xiu",
+ "髹" : "xiu",
+ "宿" : "xiu",
+ "岫" : "xiu",
+ "溴" : "xiu",
+ "吁" : "xu",
+ "盱" : "xu",
+ "顼" : "xu",
+ "糈" : "xu",
+ "醑" : "xu",
+ "洫" : "xu",
+ "溆" : "xu",
+ "蓿" : "xu",
+ "萱" : "xuan",
+ "谖" : "xuan",
+ "儇" : "xuan",
+ "煊" : "xuan",
+ "痃" : "xuan",
+ "铉" : "xuan",
+ "泫" : "xuan",
+ "碹" : "xuan",
+ "楦" : "xuan",
+ "镟" : "xuan",
+ "踅" : "xue",
+ "泶" : "xue",
+ "鳕" : "xue",
+ "埙" : "xun",
+ "曛" : "xun",
+ "窨" : "xun",
+ "獯" : "xun",
+ "峋" : "xun",
+ "洵" : "xun",
+ "恂" : "xun",
+ "浔" : "xun",
+ "鲟" : "xun",
+ "蕈" : "xun",
+ "垭" : "ya",
+ "岈" : "ya",
+ "琊" : "ya",
+ "痖" : "ya",
+ "迓" : "ya",
+ "砑" : "ya",
+ "咽" : "yan",
+ "鄢" : "yan",
+ "菸" : "yan",
+ "崦" : "yan",
+ "铅" : "qian",
+ "芫" : "yuan",
+ "兖" : "yan",
+ "琰" : "yan",
+ "罨" : "yan",
+ "厣" : "yan",
+ "焱" : "yan",
+ "酽" : "yan",
+ "谳" : "yan",
+ "鞅" : "yang",
+ "炀" : "yang",
+ "蛘" : "yang",
+ "约" : "yue",
+ "珧" : "yao",
+ "轺" : "yao",
+ "繇" : "yao",
+ "鳐" : "yao",
+ "崾" : "yao",
+ "钥" : "yao",
+ "曜" : "yao",
+ "铘" : "ye",
+ "烨" : "ye",
+ "邺" : "ye",
+ "靥" : "ye",
+ "晔" : "ye",
+ "猗" : "yi",
+ "铱" : "yi",
+ "欹" : "qi",
+ "黟" : "yi",
+ "怡" : "yi",
+ "沂" : "yi",
+ "圯" : "yi",
+ "荑" : "yi",
+ "诒" : "yi",
+ "眙" : "yi",
+ "嶷" : "yi",
+ "钇" : "yi",
+ "舣" : "yi",
+ "酏" : "yi",
+ "熠" : "yi",
+ "弋" : "yi",
+ "懿" : "yi",
+ "镒" : "yi",
+ "峄" : "yi",
+ "怿" : "yi",
+ "悒" : "yi",
+ "佾" : "yi",
+ "殪" : "yi",
+ "挹" : "yi",
+ "埸" : "yi",
+ "劓" : "yi",
+ "镱" : "yi",
+ "瘗" : "yi",
+ "癔" : "yi",
+ "翊" : "yi",
+ "蜴" : "yi",
+ "氤" : "yin",
+ "堙" : "yin",
+ "洇" : "yin",
+ "鄞" : "yin",
+ "狺" : "yin",
+ "夤" : "yin",
+ "圻" : "qi",
+ "饮" : "yin",
+ "吲" : "yin",
+ "胤" : "yin",
+ "茚" : "yin",
+ "璎" : "ying",
+ "撄" : "ying",
+ "嬴" : "ying",
+ "滢" : "ying",
+ "潆" : "ying",
+ "蓥" : "ying",
+ "瘿" : "ying",
+ "郢" : "ying",
+ "媵" : "ying",
+ "邕" : "yong",
+ "镛" : "yong",
+ "墉" : "yong",
+ "慵" : "yong",
+ "痈" : "yong",
+ "鳙" : "yong",
+ "饔" : "yong",
+ "喁" : "yong",
+ "俑" : "yong",
+ "莸" : "you",
+ "猷" : "you",
+ "疣" : "you",
+ "蚰" : "you",
+ "蝣" : "you",
+ "莜" : "you",
+ "牖" : "you",
+ "铕" : "you",
+ "卣" : "you",
+ "宥" : "you",
+ "侑" : "you",
+ "蚴" : "you",
+ "釉" : "you",
+ "馀" : "yu",
+ "萸" : "yu",
+ "禺" : "yu",
+ "妤" : "yu",
+ "欤" : "yu",
+ "觎" : "yu",
+ "窬" : "yu",
+ "蝓" : "yu",
+ "嵛" : "yu",
+ "舁" : "yu",
+ "雩" : "yu",
+ "龉" : "yu",
+ "伛" : "yu",
+ "圉" : "yu",
+ "庾" : "yu",
+ "瘐" : "yu",
+ "窳" : "yu",
+ "俣" : "yu",
+ "毓" : "yu",
+ "峪" : "yu",
+ "煜" : "yu",
+ "燠" : "yu",
+ "蓣" : "yu",
+ "饫" : "yu",
+ "阈" : "yu",
+ "鬻" : "yu",
+ "聿" : "yu",
+ "钰" : "yu",
+ "鹆" : "yu",
+ "蜮" : "yu",
+ "眢" : "yuan",
+ "箢" : "yuan",
+ "员" : "yuan",
+ "沅" : "yuan",
+ "橼" : "yuan",
+ "塬" : "yuan",
+ "爰" : "yuan",
+ "螈" : "yuan",
+ "鼋" : "yuan",
+ "掾" : "yuan",
+ "垸" : "yuan",
+ "瑗" : "yuan",
+ "刖" : "yue",
+ "瀹" : "yue",
+ "樾" : "yue",
+ "龠" : "yue",
+ "氲" : "yun",
+ "昀" : "yun",
+ "郧" : "yun",
+ "狁" : "yun",
+ "郓" : "yun",
+ "韫" : "yun",
+ "恽" : "yun",
+ "扎" : "zha",
+ "拶" : "za",
+ "咋" : "za",
+ "仔" : "zai",
+ "昝" : "zan",
+ "瓒" : "zan",
+ "藏" : "zang",
+ "奘" : "zang",
+ "唣" : "zao",
+ "择" : "ze",
+ "迮" : "ze",
+ "赜" : "ze",
+ "笮" : "ze",
+ "箦" : "ze",
+ "舴" : "ze",
+ "昃" : "ze",
+ "缯" : "zeng",
+ "罾" : "zeng",
+ "齄" : "zha",
+ "柞" : "zha",
+ "痄" : "zha",
+ "瘵" : "zhai",
+ "旃" : "zhan",
+ "璋" : "zhang",
+ "漳" : "zhang",
+ "嫜" : "zhang",
+ "鄣" : "zhang",
+ "仉" : "zhang",
+ "幛" : "zhang",
+ "着" : "zhe",
+ "啁" : "zhou",
+ "爪" : "zhao",
+ "棹" : "zhao",
+ "笊" : "zhao",
+ "摺" : "zhe",
+ "磔" : "zhe",
+ "这" : "zhe",
+ "柘" : "zhe",
+ "桢" : "zhen",
+ "蓁" : "zhen",
+ "祯" : "zhen",
+ "浈" : "zhen",
+ "畛" : "zhen",
+ "轸" : "zhen",
+ "稹" : "zhen",
+ "圳" : "zhen",
+ "徵" : "zhi",
+ "钲" : "zheng",
+ "卮" : "zhi",
+ "胝" : "zhi",
+ "祗" : "zhi",
+ "摭" : "zhi",
+ "絷" : "zhi",
+ "埴" : "zhi",
+ "轵" : "zhi",
+ "黹" : "zhi",
+ "帙" : "zhi",
+ "轾" : "zhi",
+ "贽" : "zhi",
+ "陟" : "zhi",
+ "忮" : "zhi",
+ "彘" : "zhi",
+ "膣" : "zhi",
+ "鸷" : "zhi",
+ "骘" : "zhi",
+ "踬" : "zhi",
+ "郅" : "zhi",
+ "觯" : "zhi",
+ "锺" : "zhong",
+ "螽" : "zhong",
+ "舯" : "zhong",
+ "碡" : "zhou",
+ "绉" : "zhou",
+ "荮" : "zhou",
+ "籀" : "zhou",
+ "酎" : "zhou",
+ "洙" : "zhu",
+ "邾" : "zhu",
+ "潴" : "zhu",
+ "槠" : "zhu",
+ "橥" : "zhu",
+ "舳" : "zhu",
+ "瘃" : "zhu",
+ "渚" : "zhu",
+ "麈" : "zhu",
+ "箸" : "zhu",
+ "炷" : "zhu",
+ "杼" : "zhu",
+ "翥" : "zhu",
+ "疰" : "zhu",
+ "颛" : "zhuan",
+ "赚" : "zhuan",
+ "馔" : "zhuan",
+ "僮" : "tong",
+ "缒" : "zhui",
+ "肫" : "zhun",
+ "窀" : "zhun",
+ "涿" : "zhuo",
+ "倬" : "zhuo",
+ "濯" : "zhuo",
+ "诼" : "zhuo",
+ "禚" : "zhuo",
+ "浞" : "zhuo",
+ "谘" : "zi",
+ "淄" : "zi",
+ "髭" : "zi",
+ "孳" : "zi",
+ "粢" : "zi",
+ "趑" : "zi",
+ "觜" : "zui",
+ "缁" : "zi",
+ "鲻" : "zi",
+ "嵫" : "zi",
+ "笫" : "zi",
+ "耔" : "zi",
+ "腙" : "zong",
+ "偬" : "zong",
+ "诹" : "zou",
+ "陬" : "zou",
+ "鄹" : "zou",
+ "驺" : "zou",
+ "鲰" : "zou",
+ "菹" : "ju",
+ "镞" : "zu",
+ "躜" : "zuan",
+ "缵" : "zuan",
+ "蕞" : "zui",
+ "撙" : "zun",
+ "胙" : "zuo",
+ "阿" : "a",
+ "阿" : "e",
+ "柏" : "bai",
+ "蚌" : "beng",
+ "薄" : "bo",
+ "堡" : "bao",
+ "呗" : "bei",
+ "贲" : "ben",
+ "臂" : "bi",
+ "瘪" : "bie",
+ "槟" : "bin",
+ "剥" : "bo",
+ "伯" : "bo",
+ "卜" : "bu",
+ "参" : "can",
+ "嚓" : "ca",
+ "差" : "cha",
+ "孱" : "chan",
+ "绰" : "chuo",
+ "称" : "cheng",
+ "澄" : "cheng",
+ "大" : "da",
+ "单" : "dan",
+ "得" : "de",
+ "的" : "de",
+ "地" : "di",
+ "都" : "dou",
+ "读" : "du",
+ "度" : "du",
+ "蹲" : "dun",
+ "佛" : "fo",
+ "伽" : "jia",
+ "盖" : "gai",
+ "镐" : "hao",
+ "给" : "gei",
+ "呱" : "gua",
+ "氿" : "jiu",
+ "桧" : "hui",
+ "掴" : "guo",
+ "蛤" : "ha",
+ "还" : "hai",
+ "和" : "he",
+ "核" : "he",
+ "哼" : "heng",
+ "鹄" : "hu",
+ "划" : "hua",
+ "夹" : "jia",
+ "贾" : "jia",
+ "芥" : "jie",
+ "劲" : "jin",
+ "荆" : "jing",
+ "颈" : "jing",
+ "貉" : "he",
+ "吖" : "a",
+ "啊" : "a",
+ "锕" : "a",
+ "哎" : "ai",
+ "哀" : "ai",
+ "埃" : "ai",
+ "唉" : "ai",
+ "欸" : "ai",
+ "锿" : "ai",
+ "挨" : "ai",
+ "皑" : "ai",
+ "癌" : "ai",
+ "毐" : "ai",
+ "矮" : "ai",
+ "蔼" : "ai",
+ "霭" : "ai",
+ "砹" : "ai",
+ "爱" : "ai",
+ "隘" : "ai",
+ "碍" : "ai",
+ "嗳" : "ai",
+ "嫒" : "ai",
+ "叆" : "ai",
+ "暧" : "ai",
+ "安" : "an",
+ "桉" : "an",
+ "氨" : "an",
+ "庵" : "an",
+ "谙" : "an",
+ "鹌" : "an",
+ "鞍" : "an",
+ "俺" : "an",
+ "埯" : "an",
+ "唵" : "an",
+ "铵" : "an",
+ "揞" : "an",
+ "岸" : "an",
+ "按" : "an",
+ "胺" : "an",
+ "案" : "an",
+ "暗" : "an",
+ "黯" : "an",
+ "玵" : "an",
+ "肮" : "ang",
+ "昂" : "ang",
+ "盎" : "ang",
+ "凹" : "ao",
+ "敖" : "ao",
+ "遨" : "ao",
+ "嗷" : "ao",
+ "獒" : "ao",
+ "熬" : "ao",
+ "聱" : "ao",
+ "螯" : "ao",
+ "翱" : "ao",
+ "謷" : "ao",
+ "鏖" : "ao",
+ "袄" : "ao",
+ "媪" : "ao",
+ "坳" : "ao",
+ "傲" : "ao",
+ "奥" : "ao",
+ "骜" : "ao",
+ "澳" : "ao",
+ "懊" : "ao",
+ "八" : "ba",
+ "巴" : "ba",
+ "叭" : "ba",
+ "芭" : "ba",
+ "疤" : "ba",
+ "捌" : "ba",
+ "笆" : "ba",
+ "粑" : "ba",
+ "拔" : "ba",
+ "茇" : "ba",
+ "妭" : "ba",
+ "菝" : "ba",
+ "跋" : "ba",
+ "魃" : "ba",
+ "把" : "ba",
+ "靶" : "ba",
+ "坝" : "ba",
+ "爸" : "ba",
+ "罢" : "ba",
+ "霸" : "ba",
+ "灞" : "ba",
+ "吧" : "ba",
+ "钯" : "ba",
+ "掰" : "bai",
+ "白" : "bai",
+ "百" : "bai",
+ "佰" : "bai",
+ "捭" : "bai",
+ "摆" : "bai",
+ "败" : "bai",
+ "拜" : "bai",
+ "稗" : "bai",
+ "扳" : "ban",
+ "攽" : "ban",
+ "班" : "ban",
+ "般" : "ban",
+ "颁" : "ban",
+ "斑" : "ban",
+ "搬" : "ban",
+ "瘢" : "ban",
+ "阪" : "ban",
+ "坂" : "ban",
+ "板" : "ban",
+ "版" : "ban",
+ "钣" : "ban",
+ "舨" : "ban",
+ "办" : "ban",
+ "半" : "ban",
+ "伴" : "ban",
+ "拌" : "ban",
+ "绊" : "ban",
+ "瓣" : "ban",
+ "扮" : "ban",
+ "邦" : "bang",
+ "帮" : "bang",
+ "梆" : "bang",
+ "浜" : "bang",
+ "绑" : "bang",
+ "榜" : "bang",
+ "棒" : "bang",
+ "傍" : "bang",
+ "谤" : "bang",
+ "蒡" : "bang",
+ "镑" : "bang",
+ "包" : "bao",
+ "苞" : "bao",
+ "孢" : "bao",
+ "胞" : "bao",
+ "龅" : "bao",
+ "煲" : "bao",
+ "褒" : "bao",
+ "雹" : "bao",
+ "饱" : "bao",
+ "宝" : "bao",
+ "保" : "bao",
+ "鸨" : "bao",
+ "葆" : "bao",
+ "褓" : "bao",
+ "报" : "bao",
+ "抱" : "bao",
+ "趵" : "bao",
+ "豹" : "bao",
+ "鲍" : "bao",
+ "暴" : "bao",
+ "爆" : "bao",
+ "枹" : "bao",
+ "杯" : "bei",
+ "卑" : "bei",
+ "悲" : "bei",
+ "碑" : "bei",
+ "北" : "bei",
+ "贝" : "bei",
+ "狈" : "bei",
+ "备" : "bei",
+ "背" : "bei",
+ "钡" : "bei",
+ "倍" : "bei",
+ "悖" : "bei",
+ "被" : "bei",
+ "辈" : "bei",
+ "惫" : "bei",
+ "焙" : "bei",
+ "蓓" : "bei",
+ "碚" : "bei",
+ "褙" : "bei",
+ "别" : "bei",
+ "蹩" : "bei",
+ "椑" : "bei",
+ "奔" : "ben",
+ "倴" : "ben",
+ "犇" : "ben",
+ "锛" : "ben",
+ "本" : "ben",
+ "苯" : "ben",
+ "坌" : "ben",
+ "笨" : "ben",
+ "崩" : "beng",
+ "绷" : "beng",
+ "嘣" : "beng",
+ "甭" : "beng",
+ "泵" : "beng",
+ "迸" : "beng",
+ "镚" : "beng",
+ "蹦" : "beng",
+ "屄" : "bi",
+ "逼" : "bi",
+ "荸" : "bi",
+ "鼻" : "bi",
+ "匕" : "bi",
+ "比" : "bi",
+ "吡" : "bi",
+ "沘" : "bi",
+ "妣" : "bi",
+ "彼" : "bi",
+ "秕" : "bi",
+ "笔" : "bi",
+ "俾" : "bi",
+ "鄙" : "bi",
+ "币" : "bi",
+ "必" : "bi",
+ "毕" : "bi",
+ "闭" : "bi",
+ "庇" : "bi",
+ "诐" : "bi",
+ "苾" : "bi",
+ "荜" : "bi",
+ "毖" : "bi",
+ "哔" : "bi",
+ "陛" : "bi",
+ "毙" : "bi",
+ "铋" : "bi",
+ "狴" : "bi",
+ "萆" : "bi",
+ "梐" : "bi",
+ "敝" : "bi",
+ "婢" : "bi",
+ "赑" : "bi",
+ "愎" : "bi",
+ "弼" : "bi",
+ "蓖" : "bi",
+ "痹" : "bi",
+ "滗" : "bi",
+ "碧" : "bi",
+ "蔽" : "bi",
+ "馝" : "bi",
+ "弊" : "bi",
+ "薜" : "bi",
+ "篦" : "bi",
+ "壁" : "bi",
+ "避" : "bi",
+ "髀" : "bi",
+ "璧" : "bi",
+ "芘" : "bi",
+ "边" : "bian",
+ "砭" : "bian",
+ "萹" : "bian",
+ "编" : "bian",
+ "煸" : "bian",
+ "蝙" : "bian",
+ "鳊" : "bian",
+ "鞭" : "bian",
+ "贬" : "bian",
+ "匾" : "bian",
+ "褊" : "bian",
+ "藊" : "bian",
+ "卞" : "bian",
+ "抃" : "bian",
+ "苄" : "bian",
+ "汴" : "bian",
+ "忭" : "bian",
+ "变" : "bian",
+ "遍" : "bian",
+ "辨" : "bian",
+ "辩" : "bian",
+ "辫" : "bian",
+ "标" : "biao",
+ "骉" : "biao",
+ "彪" : "biao",
+ "摽" : "biao",
+ "膘" : "biao",
+ "飙" : "biao",
+ "镖" : "biao",
+ "瀌" : "biao",
+ "镳" : "biao",
+ "表" : "biao",
+ "婊" : "biao",
+ "裱" : "biao",
+ "鳔" : "biao",
+ "憋" : "bie",
+ "鳖" : "bie",
+ "宾" : "bin",
+ "彬" : "bin",
+ "傧" : "bin",
+ "滨" : "bin",
+ "缤" : "bin",
+ "濒" : "bin",
+ "摈" : "bin",
+ "殡" : "bin",
+ "髌" : "bin",
+ "鬓" : "bin",
+ "冰" : "bing",
+ "兵" : "bing",
+ "丙" : "bing",
+ "邴" : "bing",
+ "秉" : "bing",
+ "柄" : "bing",
+ "饼" : "bing",
+ "炳" : "bing",
+ "禀" : "bing",
+ "并" : "bing",
+ "病" : "bing",
+ "摒" : "bing",
+ "拨" : "bo",
+ "波" : "bo",
+ "玻" : "bo",
+ "钵" : "bo",
+ "饽" : "bo",
+ "袯" : "bo",
+ "菠" : "bo",
+ "播" : "bo",
+ "驳" : "bo",
+ "帛" : "bo",
+ "勃" : "bo",
+ "钹" : "bo",
+ "铂" : "bo",
+ "亳" : "bo",
+ "舶" : "bo",
+ "脖" : "bo",
+ "博" : "bo",
+ "鹁" : "bo",
+ "渤" : "bo",
+ "搏" : "bo",
+ "馎" : "bo",
+ "箔" : "bo",
+ "膊" : "bo",
+ "踣" : "bo",
+ "馞" : "bo",
+ "礴" : "bo",
+ "跛" : "bo",
+ "檗" : "bo",
+ "擘" : "bo",
+ "簸" : "bo",
+ "啵" : "bo",
+ "蕃" : "bo",
+ "哱" : "bo",
+ "卟" : "bu",
+ "补" : "bu",
+ "捕" : "bu",
+ "哺" : "bu",
+ "不" : "bu",
+ "布" : "bu",
+ "步" : "bu",
+ "怖" : "bu",
+ "钚" : "bu",
+ "部" : "bu",
+ "埠" : "bu",
+ "簿" : "bu",
+ "擦" : "ca",
+ "猜" : "cai",
+ "才" : "cai",
+ "材" : "cai",
+ "财" : "cai",
+ "裁" : "cai",
+ "采" : "cai",
+ "彩" : "cai",
+ "睬" : "cai",
+ "踩" : "cai",
+ "菜" : "cai",
+ "蔡" : "cai",
+ "餐" : "can",
+ "残" : "can",
+ "蚕" : "can",
+ "惭" : "can",
+ "惨" : "can",
+ "黪" : "can",
+ "灿" : "can",
+ "粲" : "can",
+ "璨" : "can",
+ "穇" : "can",
+ "仓" : "cang",
+ "伧" : "cang",
+ "苍" : "cang",
+ "沧" : "cang",
+ "舱" : "cang",
+ "操" : "cao",
+ "糙" : "cao",
+ "曹" : "cao",
+ "嘈" : "cao",
+ "漕" : "cao",
+ "槽" : "cao",
+ "螬" : "cao",
+ "草" : "cao",
+ "册" : "ce",
+ "厕" : "ce",
+ "测" : "ce",
+ "恻" : "ce",
+ "策" : "ce",
+ "岑" : "cen",
+ "涔" : "cen",
+ "噌" : "ceng",
+ "层" : "ceng",
+ "嶒" : "ceng",
+ "蹭" : "ceng",
+ "叉" : "cha",
+ "杈" : "cha",
+ "插" : "cha",
+ "馇" : "cha",
+ "锸" : "cha",
+ "茬" : "cha",
+ "茶" : "cha",
+ "搽" : "cha",
+ "嵖" : "cha",
+ "猹" : "cha",
+ "槎" : "cha",
+ "碴" : "cha",
+ "察" : "cha",
+ "檫" : "cha",
+ "衩" : "cha",
+ "镲" : "cha",
+ "汊" : "cha",
+ "岔" : "cha",
+ "侘" : "cha",
+ "诧" : "cha",
+ "姹" : "cha",
+ "蹅" : "cha",
+ "拆" : "chai",
+ "钗" : "chai",
+ "侪" : "chai",
+ "柴" : "chai",
+ "豺" : "chai",
+ "虿" : "chai",
+ "茝" : "chai",
+ "觇" : "chan",
+ "掺" : "chan",
+ "搀" : "chan",
+ "襜" : "chan",
+ "谗" : "chan",
+ "婵" : "chan",
+ "馋" : "chan",
+ "缠" : "chan",
+ "蝉" : "chan",
+ "潺" : "chan",
+ "蟾" : "chan",
+ "巉" : "chan",
+ "产" : "chan",
+ "浐" : "chan",
+ "谄" : "chan",
+ "铲" : "chan",
+ "阐" : "chan",
+ "蒇" : "chan",
+ "骣" : "chan",
+ "冁" : "chan",
+ "忏" : "chan",
+ "颤" : "chan",
+ "羼" : "chan",
+ "韂" : "chan",
+ "伥" : "chang",
+ "昌" : "chang",
+ "菖" : "chang",
+ "猖" : "chang",
+ "娼" : "chang",
+ "肠" : "chang",
+ "尝" : "chang",
+ "常" : "chang",
+ "偿" : "chang",
+ "徜" : "chang",
+ "嫦" : "chang",
+ "厂" : "chang",
+ "场" : "chang",
+ "昶" : "chang",
+ "惝" : "chang",
+ "敞" : "chang",
+ "怅" : "chang",
+ "畅" : "chang",
+ "倡" : "chang",
+ "唱" : "chang",
+ "裳" : "chang",
+ "抄" : "chao",
+ "怊" : "chao",
+ "钞" : "chao",
+ "超" : "chao",
+ "晁" : "chao",
+ "巢" : "chao",
+ "嘲" : "chao",
+ "潮" : "chao",
+ "吵" : "chao",
+ "炒" : "chao",
+ "耖" : "chao",
+ "砗" : "che",
+ "扯" : "che",
+ "彻" : "che",
+ "坼" : "che",
+ "掣" : "che",
+ "撤" : "che",
+ "澈" : "che",
+ "瞮" : "che",
+ "抻" : "chen",
+ "郴" : "chen",
+ "嗔" : "chen",
+ "瞋" : "chen",
+ "臣" : "chen",
+ "尘" : "chen",
+ "辰" : "chen",
+ "沉" : "chen",
+ "忱" : "chen",
+ "陈" : "chen",
+ "宸" : "chen",
+ "晨" : "chen",
+ "谌" : "chen",
+ "碜" : "chen",
+ "衬" : "chen",
+ "龀" : "chen",
+ "趁" : "chen",
+ "柽" : "cheng",
+ "琤" : "cheng",
+ "撑" : "cheng",
+ "瞠" : "cheng",
+ "成" : "cheng",
+ "丞" : "cheng",
+ "呈" : "cheng",
+ "诚" : "cheng",
+ "承" : "cheng",
+ "城" : "cheng",
+ "铖" : "cheng",
+ "程" : "cheng",
+ "惩" : "cheng",
+ "酲" : "cheng",
+ "橙" : "cheng",
+ "逞" : "cheng",
+ "骋" : "cheng",
+ "秤" : "cheng",
+ "铛" : "cheng",
+ "樘" : "cheng",
+ "吃" : "chi",
+ "哧" : "chi",
+ "鸱" : "chi",
+ "蚩" : "chi",
+ "笞" : "chi",
+ "嗤" : "chi",
+ "痴" : "chi",
+ "媸" : "chi",
+ "魑" : "chi",
+ "池" : "chi",
+ "弛" : "chi",
+ "驰" : "chi",
+ "迟" : "chi",
+ "茌" : "chi",
+ "持" : "chi",
+ "踟" : "chi",
+ "尺" : "chi",
+ "齿" : "chi",
+ "侈" : "chi",
+ "耻" : "chi",
+ "豉" : "chi",
+ "褫" : "chi",
+ "彳" : "chi",
+ "叱" : "chi",
+ "斥" : "chi",
+ "赤" : "chi",
+ "饬" : "chi",
+ "炽" : "chi",
+ "翅" : "chi",
+ "敕" : "chi",
+ "啻" : "chi",
+ "傺" : "chi",
+ "匙" : "chi",
+ "冲" : "chong",
+ "充" : "chong",
+ "忡" : "chong",
+ "茺" : "chong",
+ "舂" : "chong",
+ "憧" : "chong",
+ "艟" : "chong",
+ "虫" : "chong",
+ "崇" : "chong",
+ "宠" : "chong",
+ "铳" : "chong",
+ "抽" : "chou",
+ "瘳" : "chou",
+ "惆" : "chou",
+ "绸" : "chou",
+ "畴" : "chou",
+ "酬" : "chou",
+ "稠" : "chou",
+ "愁" : "chou",
+ "筹" : "chou",
+ "踌" : "chou",
+ "丑" : "chou",
+ "瞅" : "chou",
+ "出" : "chu",
+ "初" : "chu",
+ "樗" : "chu",
+ "刍" : "chu",
+ "除" : "chu",
+ "厨" : "chu",
+ "锄" : "chu",
+ "滁" : "chu",
+ "蜍" : "chu",
+ "雏" : "chu",
+ "橱" : "chu",
+ "躇" : "chu",
+ "蹰" : "chu",
+ "杵" : "chu",
+ "础" : "chu",
+ "储" : "chu",
+ "楚" : "chu",
+ "褚" : "chu",
+ "亍" : "chu",
+ "处" : "chu",
+ "怵" : "chu",
+ "绌" : "chu",
+ "搐" : "chu",
+ "触" : "chu",
+ "憷" : "chu",
+ "黜" : "chu",
+ "矗" : "chu",
+ "揣" : "chuai",
+ "搋" : "chuai",
+ "膗" : "chuai",
+ "踹" : "chuai",
+ "川" : "chuan",
+ "氚" : "chuan",
+ "穿" : "chuan",
+ "舡" : "chuan",
+ "船" : "chuan",
+ "遄" : "chuan",
+ "椽" : "chuan",
+ "舛" : "chuan",
+ "喘" : "chuan",
+ "串" : "chuan",
+ "钏" : "chuan",
+ "疮" : "chuang",
+ "窗" : "chuang",
+ "床" : "chuang",
+ "闯" : "chuang",
+ "创" : "chuang",
+ "怆" : "chuang",
+ "吹" : "chui",
+ "炊" : "chui",
+ "垂" : "chui",
+ "陲" : "chui",
+ "捶" : "chui",
+ "棰" : "chui",
+ "槌" : "chui",
+ "锤" : "chui",
+ "春" : "chun",
+ "瑃" : "chun",
+ "椿" : "chun",
+ "蝽" : "chun",
+ "纯" : "chun",
+ "莼" : "chun",
+ "唇" : "chun",
+ "淳" : "chun",
+ "鹑" : "chun",
+ "醇" : "chun",
+ "蠢" : "chun",
+ "踔" : "chuo",
+ "戳" : "chuo",
+ "啜" : "chuo",
+ "惙" : "chuo",
+ "辍" : "chuo",
+ "龊" : "chuo",
+ "歠" : "chuo",
+ "疵" : "ci",
+ "词" : "ci",
+ "茈" : "ci",
+ "茨" : "ci",
+ "祠" : "ci",
+ "瓷" : "ci",
+ "辞" : "ci",
+ "慈" : "ci",
+ "磁" : "ci",
+ "雌" : "ci",
+ "鹚" : "ci",
+ "糍" : "ci",
+ "此" : "ci",
+ "泚" : "ci",
+ "跐" : "ci",
+ "次" : "ci",
+ "刺" : "ci",
+ "佽" : "ci",
+ "赐" : "ci",
+ "匆" : "cong",
+ "苁" : "cong",
+ "囱" : "cong",
+ "枞" : "cong",
+ "葱" : "cong",
+ "骢" : "cong",
+ "聪" : "cong",
+ "从" : "cong",
+ "丛" : "cong",
+ "淙" : "cong",
+ "悰" : "cong",
+ "琮" : "cong",
+ "凑" : "cou",
+ "辏" : "cou",
+ "腠" : "cou",
+ "粗" : "cu",
+ "徂" : "cu",
+ "殂" : "cu",
+ "促" : "cu",
+ "猝" : "cu",
+ "蔟" : "cu",
+ "醋" : "cu",
+ "踧" : "cu",
+ "簇" : "cu",
+ "蹙" : "cu",
+ "蹴" : "cu",
+ "汆" : "cuan",
+ "撺" : "cuan",
+ "镩" : "cuan",
+ "蹿" : "cuan",
+ "窜" : "cuan",
+ "篡" : "cuan",
+ "崔" : "cui",
+ "催" : "cui",
+ "摧" : "cui",
+ "璀" : "cui",
+ "脆" : "cui",
+ "萃" : "cui",
+ "啐" : "cui",
+ "淬" : "cui",
+ "悴" : "cui",
+ "毳" : "cui",
+ "瘁" : "cui",
+ "粹" : "cui",
+ "翠" : "cui",
+ "村" : "cun",
+ "皴" : "cun",
+ "存" : "cun",
+ "忖" : "cun",
+ "寸" : "cun",
+ "吋" : "cun",
+ "搓" : "cuo",
+ "磋" : "cuo",
+ "蹉" : "cuo",
+ "嵯" : "cuo",
+ "矬" : "cuo",
+ "痤" : "cuo",
+ "脞" : "cuo",
+ "挫" : "cuo",
+ "莝" : "cuo",
+ "厝" : "cuo",
+ "措" : "cuo",
+ "锉" : "cuo",
+ "错" : "cuo",
+ "酇" : "cuo",
+ "咑" : "da",
+ "垯" : "da",
+ "耷" : "da",
+ "搭" : "da",
+ "褡" : "da",
+ "达" : "da",
+ "怛" : "da",
+ "妲" : "da",
+ "荙" : "da",
+ "笪" : "da",
+ "答" : "da",
+ "跶" : "da",
+ "靼" : "da",
+ "瘩" : "da",
+ "鞑" : "da",
+ "打" : "da",
+ "呆" : "dai",
+ "歹" : "dai",
+ "逮" : "dai",
+ "傣" : "dai",
+ "代" : "dai",
+ "岱" : "dai",
+ "迨" : "dai",
+ "玳" : "dai",
+ "带" : "dai",
+ "殆" : "dai",
+ "贷" : "dai",
+ "待" : "dai",
+ "怠" : "dai",
+ "袋" : "dai",
+ "叇" : "dai",
+ "戴" : "dai",
+ "黛" : "dai",
+ "襶" : "dai",
+ "呔" : "dai",
+ "丹" : "dan",
+ "担" : "dan",
+ "眈" : "dan",
+ "耽" : "dan",
+ "郸" : "dan",
+ "聃" : "dan",
+ "殚" : "dan",
+ "瘅" : "dan",
+ "箪" : "dan",
+ "儋" : "dan",
+ "胆" : "dan",
+ "疸" : "dan",
+ "掸" : "dan",
+ "亶" : "dan",
+ "旦" : "dan",
+ "但" : "dan",
+ "诞" : "dan",
+ "萏" : "dan",
+ "啖" : "dan",
+ "淡" : "dan",
+ "惮" : "dan",
+ "蛋" : "dan",
+ "氮" : "dan",
+ "赕" : "dan",
+ "当" : "dang",
+ "裆" : "dang",
+ "挡" : "dang",
+ "档" : "dang",
+ "党" : "dang",
+ "谠" : "dang",
+ "凼" : "dang",
+ "砀" : "dang",
+ "宕" : "dang",
+ "荡" : "dang",
+ "菪" : "dang",
+ "刀" : "dao",
+ "忉" : "dao",
+ "氘" : "dao",
+ "舠" : "dao",
+ "导" : "dao",
+ "岛" : "dao",
+ "捣" : "dao",
+ "倒" : "dao",
+ "捯" : "dao",
+ "祷" : "dao",
+ "蹈" : "dao",
+ "到" : "dao",
+ "盗" : "dao",
+ "悼" : "dao",
+ "道" : "dao",
+ "稻" : "dao",
+ "焘" : "dao",
+ "锝" : "de",
+ "嘚" : "de",
+ "德" : "de",
+ "扽" : "den",
+ "灯" : "deng",
+ "登" : "deng",
+ "噔" : "deng",
+ "蹬" : "deng",
+ "等" : "deng",
+ "戥" : "deng",
+ "邓" : "deng",
+ "僜" : "deng",
+ "凳" : "deng",
+ "嶝" : "deng",
+ "磴" : "deng",
+ "瞪" : "deng",
+ "镫" : "deng",
+ "低" : "di",
+ "羝" : "di",
+ "堤" : "di",
+ "嘀" : "di",
+ "滴" : "di",
+ "狄" : "di",
+ "迪" : "di",
+ "籴" : "di",
+ "荻" : "di",
+ "敌" : "di",
+ "涤" : "di",
+ "笛" : "di",
+ "觌" : "di",
+ "嫡" : "di",
+ "镝" : "di",
+ "氐" : "di",
+ "邸" : "di",
+ "诋" : "di",
+ "抵" : "di",
+ "底" : "di",
+ "柢" : "di",
+ "砥" : "di",
+ "骶" : "di",
+ "玓" : "di",
+ "弟" : "di",
+ "帝" : "di",
+ "递" : "di",
+ "娣" : "di",
+ "第" : "di",
+ "谛" : "di",
+ "蒂" : "di",
+ "棣" : "di",
+ "睇" : "di",
+ "缔" : "di",
+ "碲" : "di",
+ "嗲" : "dia",
+ "掂" : "dian",
+ "滇" : "dian",
+ "颠" : "dian",
+ "巅" : "dian",
+ "癫" : "dian",
+ "典" : "dian",
+ "点" : "dian",
+ "碘" : "dian",
+ "踮" : "dian",
+ "电" : "dian",
+ "甸" : "dian",
+ "阽" : "dian",
+ "坫" : "dian",
+ "店" : "dian",
+ "玷" : "dian",
+ "垫" : "dian",
+ "钿" : "dian",
+ "淀" : "dian",
+ "惦" : "dian",
+ "奠" : "dian",
+ "殿" : "dian",
+ "靛" : "dian",
+ "刁" : "diao",
+ "叼" : "diao",
+ "汈" : "diao",
+ "凋" : "diao",
+ "貂" : "diao",
+ "碉" : "diao",
+ "雕" : "diao",
+ "鲷" : "diao",
+ "屌" : "diao",
+ "吊" : "diao",
+ "钓" : "diao",
+ "窎" : "diao",
+ "掉" : "diao",
+ "铫" : "diao",
+ "爹" : "die",
+ "跌" : "die",
+ "迭" : "die",
+ "谍" : "die",
+ "耋" : "die",
+ "喋" : "die",
+ "牒" : "die",
+ "叠" : "die",
+ "碟" : "die",
+ "嵽" : "die",
+ "蝶" : "die",
+ "蹀" : "die",
+ "鲽" : "die",
+ "仃" : "ding",
+ "叮" : "ding",
+ "玎" : "ding",
+ "盯" : "ding",
+ "町" : "ding",
+ "耵" : "ding",
+ "顶" : "ding",
+ "酊" : "ding",
+ "鼎" : "ding",
+ "订" : "ding",
+ "钉" : "ding",
+ "定" : "ding",
+ "啶" : "ding",
+ "腚" : "ding",
+ "碇" : "ding",
+ "锭" : "ding",
+ "丢" : "diu",
+ "铥" : "diu",
+ "东" : "dong",
+ "冬" : "dong",
+ "咚" : "dong",
+ "氡" : "dong",
+ "鸫" : "dong",
+ "董" : "dong",
+ "懂" : "dong",
+ "动" : "dong",
+ "冻" : "dong",
+ "侗" : "dong",
+ "栋" : "dong",
+ "胨" : "dong",
+ "洞" : "dong",
+ "胴" : "dong",
+ "兜" : "dou",
+ "蔸" : "dou",
+ "篼" : "dou",
+ "抖" : "dou",
+ "陡" : "dou",
+ "蚪" : "dou",
+ "斗" : "dou",
+ "豆" : "dou",
+ "逗" : "dou",
+ "痘" : "dou",
+ "窦" : "dou",
+ "督" : "du",
+ "嘟" : "du",
+ "毒" : "du",
+ "独" : "du",
+ "渎" : "du",
+ "椟" : "du",
+ "犊" : "du",
+ "牍" : "du",
+ "黩" : "du",
+ "髑" : "du",
+ "厾" : "du",
+ "笃" : "du",
+ "堵" : "du",
+ "赌" : "du",
+ "睹" : "du",
+ "杜" : "du",
+ "肚" : "du",
+ "妒" : "du",
+ "渡" : "du",
+ "镀" : "du",
+ "蠹" : "du",
+ "端" : "duan",
+ "短" : "duan",
+ "段" : "duan",
+ "断" : "duan",
+ "缎" : "duan",
+ "椴" : "duan",
+ "锻" : "duan",
+ "簖" : "duan",
+ "堆" : "dui",
+ "队" : "dui",
+ "对" : "dui",
+ "兑" : "dui",
+ "怼" : "dui",
+ "憝" : "dui",
+ "吨" : "dun",
+ "惇" : "dun",
+ "敦" : "dun",
+ "墩" : "dun",
+ "礅" : "dun",
+ "盹" : "dun",
+ "趸" : "dun",
+ "沌" : "dun",
+ "炖" : "dun",
+ "砘" : "dun",
+ "钝" : "dun",
+ "盾" : "dun",
+ "顿" : "dun",
+ "遁" : "dun",
+ "多" : "duo",
+ "咄" : "duo",
+ "哆" : "duo",
+ "掇" : "duo",
+ "裰" : "duo",
+ "夺" : "duo",
+ "踱" : "duo",
+ "朵" : "duo",
+ "垛" : "duo",
+ "哚" : "duo",
+ "躲" : "duo",
+ "亸" : "duo",
+ "剁" : "duo",
+ "舵" : "duo",
+ "堕" : "duo",
+ "惰" : "duo",
+ "跺" : "duo",
+ "屙" : "e",
+ "婀" : "e",
+ "讹" : "e",
+ "囮" : "e",
+ "俄" : "e",
+ "莪" : "e",
+ "峨" : "e",
+ "娥" : "e",
+ "锇" : "e",
+ "鹅" : "e",
+ "蛾" : "e",
+ "额" : "e",
+ "厄" : "e",
+ "扼" : "e",
+ "苊" : "e",
+ "呃" : "e",
+ "垩" : "e",
+ "饿" : "e",
+ "鄂" : "e",
+ "谔" : "e",
+ "萼" : "e",
+ "遏" : "e",
+ "愕" : "e",
+ "腭" : "e",
+ "颚" : "e",
+ "噩" : "e",
+ "鳄" : "e",
+ "恩" : "en",
+ "蒽" : "en",
+ "摁" : "en",
+ "鞥" : "eng",
+ "儿" : "er",
+ "而" : "er",
+ "鸸" : "er",
+ "尔" : "er",
+ "耳" : "er",
+ "迩" : "er",
+ "饵" : "er",
+ "洱" : "er",
+ "铒" : "er",
+ "二" : "er",
+ "贰" : "er",
+ "发" : "fa",
+ "乏" : "fa",
+ "伐" : "fa",
+ "罚" : "fa",
+ "垡" : "fa",
+ "阀" : "fa",
+ "筏" : "fa",
+ "法" : "fa",
+ "砝" : "fa",
+ "珐" : "fa",
+ "帆" : "fan",
+ "幡" : "fan",
+ "藩" : "fan",
+ "翻" : "fan",
+ "凡" : "fan",
+ "矾" : "fan",
+ "钒" : "fan",
+ "烦" : "fan",
+ "樊" : "fan",
+ "燔" : "fan",
+ "繁" : "fan",
+ "蹯" : "fan",
+ "蘩" : "fan",
+ "反" : "fan",
+ "返" : "fan",
+ "犯" : "fan",
+ "饭" : "fan",
+ "泛" : "fan",
+ "范" : "fan",
+ "贩" : "fan",
+ "畈" : "fan",
+ "梵" : "fan",
+ "方" : "fang",
+ "邡" : "fang",
+ "坊" : "fang",
+ "芳" : "fang",
+ "枋" : "fang",
+ "钫" : "fang",
+ "防" : "fang",
+ "妨" : "fang",
+ "肪" : "fang",
+ "房" : "fang",
+ "鲂" : "fang",
+ "仿" : "fang",
+ "访" : "fang",
+ "纺" : "fang",
+ "舫" : "fang",
+ "放" : "fang",
+ "飞" : "fei",
+ "妃" : "fei",
+ "非" : "fei",
+ "菲" : "fei",
+ "啡" : "fei",
+ "绯" : "fei",
+ "扉" : "fei",
+ "肥" : "fei",
+ "淝" : "fei",
+ "腓" : "fei",
+ "匪" : "fei",
+ "诽" : "fei",
+ "悱" : "fei",
+ "棐" : "fei",
+ "斐" : "fei",
+ "榧" : "fei",
+ "翡" : "fei",
+ "篚" : "fei",
+ "吠" : "fei",
+ "肺" : "fei",
+ "狒" : "fei",
+ "废" : "fei",
+ "沸" : "fei",
+ "费" : "fei",
+ "痱" : "fei",
+ "镄" : "fei",
+ "分" : "fen",
+ "芬" : "fen",
+ "吩" : "fen",
+ "纷" : "fen",
+ "氛" : "fen",
+ "酚" : "fen",
+ "坟" : "fen",
+ "汾" : "fen",
+ "棼" : "fen",
+ "焚" : "fen",
+ "鼢" : "fen",
+ "粉" : "fen",
+ "份" : "fen",
+ "奋" : "fen",
+ "忿" : "fen",
+ "偾" : "fen",
+ "粪" : "fen",
+ "愤" : "fen",
+ "丰" : "feng",
+ "风" : "feng",
+ "沣" : "feng",
+ "枫" : "feng",
+ "封" : "feng",
+ "砜" : "feng",
+ "疯" : "feng",
+ "峰" : "feng",
+ "烽" : "feng",
+ "葑" : "feng",
+ "锋" : "feng",
+ "蜂" : "feng",
+ "酆" : "feng",
+ "冯" : "feng",
+ "逢" : "feng",
+ "缝" : "feng",
+ "讽" : "feng",
+ "唪" : "feng",
+ "凤" : "feng",
+ "奉" : "feng",
+ "俸" : "feng",
+ "缶" : "fou",
+ "夫" : "fu",
+ "呋" : "fu",
+ "肤" : "fu",
+ "麸" : "fu",
+ "跗" : "fu",
+ "稃" : "fu",
+ "孵" : "fu",
+ "敷" : "fu",
+ "弗" : "fu",
+ "伏" : "fu",
+ "凫" : "fu",
+ "扶" : "fu",
+ "芙" : "fu",
+ "孚" : "fu",
+ "拂" : "fu",
+ "苻" : "fu",
+ "服" : "fu",
+ "怫" : "fu",
+ "茯" : "fu",
+ "氟" : "fu",
+ "俘" : "fu",
+ "浮" : "fu",
+ "符" : "fu",
+ "匐" : "fu",
+ "涪" : "fu",
+ "艴" : "fu",
+ "幅" : "fu",
+ "辐" : "fu",
+ "蜉" : "fu",
+ "福" : "fu",
+ "蝠" : "fu",
+ "抚" : "fu",
+ "甫" : "fu",
+ "拊" : "fu",
+ "斧" : "fu",
+ "府" : "fu",
+ "俯" : "fu",
+ "釜" : "fu",
+ "辅" : "fu",
+ "腑" : "fu",
+ "腐" : "fu",
+ "父" : "fu",
+ "讣" : "fu",
+ "付" : "fu",
+ "负" : "fu",
+ "妇" : "fu",
+ "附" : "fu",
+ "咐" : "fu",
+ "阜" : "fu",
+ "驸" : "fu",
+ "赴" : "fu",
+ "复" : "fu",
+ "副" : "fu",
+ "赋" : "fu",
+ "傅" : "fu",
+ "富" : "fu",
+ "腹" : "fu",
+ "缚" : "fu",
+ "赙" : "fu",
+ "蝮" : "fu",
+ "覆" : "fu",
+ "馥" : "fu",
+ "袱" : "fu",
+ "旮" : "ga",
+ "嘎" : "ga",
+ "钆" : "ga",
+ "尜" : "ga",
+ "尕" : "ga",
+ "尬" : "ga",
+ "该" : "gai",
+ "垓" : "gai",
+ "荄" : "gai",
+ "赅" : "gai",
+ "改" : "gai",
+ "丐" : "gai",
+ "钙" : "gai",
+ "溉" : "gai",
+ "概" : "gai",
+ "甘" : "gan",
+ "玕" : "gan",
+ "肝" : "gan",
+ "坩" : "gan",
+ "苷" : "gan",
+ "矸" : "gan",
+ "泔" : "gan",
+ "柑" : "gan",
+ "竿" : "gan",
+ "酐" : "gan",
+ "疳" : "gan",
+ "尴" : "gan",
+ "杆" : "gan",
+ "秆" : "gan",
+ "赶" : "gan",
+ "敢" : "gan",
+ "感" : "gan",
+ "澉" : "gan",
+ "橄" : "gan",
+ "擀" : "gan",
+ "干" : "gan",
+ "旰" : "gan",
+ "绀" : "gan",
+ "淦" : "gan",
+ "骭" : "gan",
+ "赣" : "gan",
+ "冈" : "gang",
+ "冮" : "gang",
+ "刚" : "gang",
+ "肛" : "gang",
+ "纲" : "gang",
+ "钢" : "gang",
+ "缸" : "gang",
+ "罡" : "gang",
+ "岗" : "gang",
+ "港" : "gang",
+ "杠" : "gang",
+ "皋" : "gao",
+ "高" : "gao",
+ "羔" : "gao",
+ "睾" : "gao",
+ "膏" : "gao",
+ "篙" : "gao",
+ "糕" : "gao",
+ "杲" : "gao",
+ "搞" : "gao",
+ "槁" : "gao",
+ "稿" : "gao",
+ "告" : "gao",
+ "郜" : "gao",
+ "诰" : "gao",
+ "锆" : "gao",
+ "戈" : "ge",
+ "圪" : "ge",
+ "纥" : "ge",
+ "疙" : "ge",
+ "哥" : "ge",
+ "胳" : "ge",
+ "鸽" : "ge",
+ "袼" : "ge",
+ "搁" : "ge",
+ "割" : "ge",
+ "歌" : "ge",
+ "革" : "ge",
+ "阁" : "ge",
+ "格" : "ge",
+ "隔" : "ge",
+ "嗝" : "ge",
+ "膈" : "ge",
+ "骼" : "ge",
+ "镉" : "ge",
+ "舸" : "ge",
+ "葛" : "ge",
+ "个" : "ge",
+ "各" : "ge",
+ "虼" : "ge",
+ "硌" : "ge",
+ "铬" : "ge",
+ "根" : "gen",
+ "跟" : "gen",
+ "哏" : "gen",
+ "亘" : "gen",
+ "艮" : "gen",
+ "茛" : "gen",
+ "庚" : "geng",
+ "耕" : "geng",
+ "浭" : "geng",
+ "赓" : "geng",
+ "羹" : "geng",
+ "埂" : "geng",
+ "耿" : "geng",
+ "哽" : "geng",
+ "绠" : "geng",
+ "梗" : "geng",
+ "鲠" : "geng",
+ "更" : "geng",
+ "工" : "gong",
+ "弓" : "gong",
+ "公" : "gong",
+ "功" : "gong",
+ "攻" : "gong",
+ "肱" : "gong",
+ "宫" : "gong",
+ "恭" : "gong",
+ "蚣" : "gong",
+ "躬" : "gong",
+ "龚" : "gong",
+ "塨" : "gong",
+ "觥" : "gong",
+ "巩" : "gong",
+ "汞" : "gong",
+ "拱" : "gong",
+ "珙" : "gong",
+ "共" : "gong",
+ "贡" : "gong",
+ "供" : "gong",
+ "勾" : "gou",
+ "佝" : "gou",
+ "沟" : "gou",
+ "钩" : "gou",
+ "篝" : "gou",
+ "苟" : "gou",
+ "岣" : "gou",
+ "狗" : "gou",
+ "枸" : "gou",
+ "构" : "gou",
+ "购" : "gou",
+ "诟" : "gou",
+ "垢" : "gou",
+ "够" : "gou",
+ "彀" : "gou",
+ "媾" : "gou",
+ "觏" : "gou",
+ "估" : "gu",
+ "咕" : "gu",
+ "沽" : "gu",
+ "孤" : "gu",
+ "姑" : "gu",
+ "轱" : "gu",
+ "鸪" : "gu",
+ "菰" : "gu",
+ "菇" : "gu",
+ "蛄" : "gu",
+ "蓇" : "gu",
+ "辜" : "gu",
+ "酤" : "gu",
+ "觚" : "gu",
+ "毂" : "gu",
+ "箍" : "gu",
+ "古" : "gu",
+ "谷" : "gu",
+ "汩" : "gu",
+ "诂" : "gu",
+ "股" : "gu",
+ "骨" : "gu",
+ "牯" : "gu",
+ "钴" : "gu",
+ "羖" : "gu",
+ "蛊" : "gu",
+ "鼓" : "gu",
+ "榾" : "gu",
+ "鹘" : "gu",
+ "臌" : "gu",
+ "瀔" : "gu",
+ "固" : "gu",
+ "故" : "gu",
+ "顾" : "gu",
+ "梏" : "gu",
+ "崮" : "gu",
+ "雇" : "gu",
+ "锢" : "gu",
+ "痼" : "gu",
+ "瓜" : "gua",
+ "刮" : "gua",
+ "胍" : "gua",
+ "鸹" : "gua",
+ "剐" : "gua",
+ "寡" : "gua",
+ "卦" : "gua",
+ "诖" : "gua",
+ "挂" : "gua",
+ "褂" : "gua",
+ "乖" : "guai",
+ "拐" : "guai",
+ "怪" : "guai",
+ "关" : "guan",
+ "观" : "guan",
+ "官" : "guan",
+ "倌" : "guan",
+ "蒄" : "guan",
+ "棺" : "guan",
+ "瘝" : "guan",
+ "鳏" : "guan",
+ "馆" : "guan",
+ "管" : "guan",
+ "贯" : "guan",
+ "冠" : "guan",
+ "掼" : "guan",
+ "惯" : "guan",
+ "祼" : "guan",
+ "盥" : "guan",
+ "灌" : "guan",
+ "瓘" : "guan",
+ "鹳" : "guan",
+ "罐" : "guan",
+ "琯" : "guan",
+ "光" : "guang",
+ "咣" : "guang",
+ "胱" : "guang",
+ "广" : "guang",
+ "犷" : "guang",
+ "桄" : "guang",
+ "逛" : "guang",
+ "归" : "gui",
+ "圭" : "gui",
+ "龟" : "gui",
+ "妫" : "gui",
+ "规" : "gui",
+ "皈" : "gui",
+ "闺" : "gui",
+ "硅" : "gui",
+ "瑰" : "gui",
+ "鲑" : "gui",
+ "宄" : "gui",
+ "轨" : "gui",
+ "庋" : "gui",
+ "匦" : "gui",
+ "诡" : "gui",
+ "鬼" : "gui",
+ "姽" : "gui",
+ "癸" : "gui",
+ "晷" : "gui",
+ "簋" : "gui",
+ "柜" : "gui",
+ "炅" : "gui",
+ "刿" : "gui",
+ "刽" : "gui",
+ "贵" : "gui",
+ "桂" : "gui",
+ "跪" : "gui",
+ "鳜" : "gui",
+ "衮" : "gun",
+ "绲" : "gun",
+ "辊" : "gun",
+ "滚" : "gun",
+ "磙" : "gun",
+ "鲧" : "gun",
+ "棍" : "gun",
+ "埚" : "guo",
+ "郭" : "guo",
+ "啯" : "guo",
+ "崞" : "guo",
+ "聒" : "guo",
+ "锅" : "guo",
+ "蝈" : "guo",
+ "国" : "guo",
+ "帼" : "guo",
+ "虢" : "guo",
+ "果" : "guo",
+ "椁" : "guo",
+ "蜾" : "guo",
+ "裹" : "guo",
+ "过" : "guo",
+ "哈" : "ha",
+ "铪" : "ha",
+ "孩" : "hai",
+ "骸" : "hai",
+ "胲" : "hai",
+ "海" : "hai",
+ "醢" : "hai",
+ "亥" : "hai",
+ "骇" : "hai",
+ "害" : "hai",
+ "嗐" : "hai",
+ "嗨" : "hai",
+ "顸" : "han",
+ "蚶" : "han",
+ "酣" : "han",
+ "憨" : "han",
+ "鼾" : "han",
+ "邗" : "han",
+ "邯" : "han",
+ "含" : "han",
+ "函" : "han",
+ "晗" : "han",
+ "焓" : "han",
+ "涵" : "han",
+ "韩" : "han",
+ "寒" : "han",
+ "罕" : "han",
+ "喊" : "han",
+ "蔊" : "han",
+ "汉" : "han",
+ "汗" : "han",
+ "旱" : "han",
+ "捍" : "han",
+ "悍" : "han",
+ "菡" : "han",
+ "焊" : "han",
+ "撖" : "han",
+ "撼" : "han",
+ "翰" : "han",
+ "憾" : "han",
+ "瀚" : "han",
+ "夯" : "hang",
+ "杭" : "hang",
+ "绗" : "hang",
+ "航" : "hang",
+ "沆" : "hang",
+ "蒿" : "hao",
+ "薅" : "hao",
+ "嚆" : "hao",
+ "蚝" : "hao",
+ "毫" : "hao",
+ "嗥" : "hao",
+ "豪" : "hao",
+ "壕" : "hao",
+ "嚎" : "hao",
+ "濠" : "hao",
+ "好" : "hao",
+ "郝" : "hao",
+ "号" : "hao",
+ "昊" : "hao",
+ "耗" : "hao",
+ "浩" : "hao",
+ "皓" : "hao",
+ "滈" : "hao",
+ "颢" : "hao",
+ "灏" : "hao",
+ "诃" : "he",
+ "呵" : "he",
+ "喝" : "he",
+ "嗬" : "he",
+ "禾" : "he",
+ "合" : "he",
+ "何" : "he",
+ "劾" : "he",
+ "河" : "he",
+ "曷" : "he",
+ "阂" : "he",
+ "盍" : "he",
+ "荷" : "he",
+ "菏" : "he",
+ "盒" : "he",
+ "涸" : "he",
+ "颌" : "he",
+ "阖" : "he",
+ "贺" : "he",
+ "赫" : "he",
+ "褐" : "he",
+ "鹤" : "he",
+ "壑" : "he",
+ "黑" : "hei",
+ "嘿" : "hei",
+ "痕" : "hen",
+ "很" : "hen",
+ "狠" : "hen",
+ "恨" : "hen",
+ "亨" : "heng",
+ "恒" : "heng",
+ "珩" : "heng",
+ "横" : "heng",
+ "衡" : "heng",
+ "蘅" : "heng",
+ "啈" : "heng",
+ "轰" : "hong",
+ "訇" : "hong",
+ "烘" : "hong",
+ "薨" : "hong",
+ "弘" : "hong",
+ "红" : "hong",
+ "闳" : "hong",
+ "宏" : "hong",
+ "荭" : "hong",
+ "虹" : "hong",
+ "竑" : "hong",
+ "洪" : "hong",
+ "鸿" : "hong",
+ "哄" : "hong",
+ "讧" : "hong",
+ "吽" : "hong",
+ "齁" : "hou",
+ "侯" : "hou",
+ "喉" : "hou",
+ "猴" : "hou",
+ "瘊" : "hou",
+ "骺" : "hou",
+ "篌" : "hou",
+ "糇" : "hou",
+ "吼" : "hou",
+ "后" : "hou",
+ "郈" : "hou",
+ "厚" : "hou",
+ "垕" : "hou",
+ "逅" : "hou",
+ "候" : "hou",
+ "堠" : "hou",
+ "鲎" : "hou",
+ "乎" : "hu",
+ "呼" : "hu",
+ "忽" : "hu",
+ "轷" : "hu",
+ "烀" : "hu",
+ "惚" : "hu",
+ "滹" : "hu",
+ "囫" : "hu",
+ "狐" : "hu",
+ "弧" : "hu",
+ "胡" : "hu",
+ "壶" : "hu",
+ "斛" : "hu",
+ "葫" : "hu",
+ "猢" : "hu",
+ "湖" : "hu",
+ "瑚" : "hu",
+ "鹕" : "hu",
+ "槲" : "hu",
+ "蝴" : "hu",
+ "糊" : "hu",
+ "醐" : "hu",
+ "觳" : "hu",
+ "虎" : "hu",
+ "唬" : "hu",
+ "琥" : "hu",
+ "互" : "hu",
+ "户" : "hu",
+ "冱" : "hu",
+ "护" : "hu",
+ "沪" : "hu",
+ "枑" : "hu",
+ "怙" : "hu",
+ "戽" : "hu",
+ "笏" : "hu",
+ "瓠" : "hu",
+ "扈" : "hu",
+ "鹱" : "hu",
+ "花" : "hua",
+ "砉" : "hua",
+ "华" : "hua",
+ "哗" : "hua",
+ "骅" : "hua",
+ "铧" : "hua",
+ "猾" : "hua",
+ "滑" : "hua",
+ "化" : "hua",
+ "画" : "hua",
+ "话" : "hua",
+ "桦" : "hua",
+ "婳" : "hua",
+ "觟" : "hua",
+ "怀" : "huai",
+ "徊" : "huai",
+ "淮" : "huai",
+ "槐" : "huai",
+ "踝" : "huai",
+ "耲" : "huai",
+ "坏" : "huai",
+ "欢" : "huan",
+ "獾" : "huan",
+ "环" : "huan",
+ "洹" : "huan",
+ "桓" : "huan",
+ "萑" : "huan",
+ "寰" : "huan",
+ "缳" : "huan",
+ "缓" : "huan",
+ "幻" : "huan",
+ "奂" : "huan",
+ "宦" : "huan",
+ "换" : "huan",
+ "唤" : "huan",
+ "涣" : "huan",
+ "浣" : "huan",
+ "患" : "huan",
+ "焕" : "huan",
+ "痪" : "huan",
+ "豢" : "huan",
+ "漶" : "huan",
+ "鲩" : "huan",
+ "擐" : "huan",
+ "肓" : "huang",
+ "荒" : "huang",
+ "塃" : "huang",
+ "慌" : "huang",
+ "皇" : "huang",
+ "黄" : "huang",
+ "凰" : "huang",
+ "隍" : "huang",
+ "喤" : "huang",
+ "遑" : "huang",
+ "徨" : "huang",
+ "湟" : "huang",
+ "惶" : "huang",
+ "媓" : "huang",
+ "煌" : "huang",
+ "锽" : "huang",
+ "潢" : "huang",
+ "璜" : "huang",
+ "蝗" : "huang",
+ "篁" : "huang",
+ "艎" : "huang",
+ "磺" : "huang",
+ "癀" : "huang",
+ "蟥" : "huang",
+ "簧" : "huang",
+ "鳇" : "huang",
+ "恍" : "huang",
+ "晃" : "huang",
+ "谎" : "huang",
+ "幌" : "huang",
+ "滉" : "huang",
+ "皝" : "huang",
+ "灰" : "hui",
+ "诙" : "hui",
+ "挥" : "hui",
+ "恢" : "hui",
+ "晖" : "hui",
+ "辉" : "hui",
+ "麾" : "hui",
+ "徽" : "hui",
+ "隳" : "hui",
+ "回" : "hui",
+ "茴" : "hui",
+ "洄" : "hui",
+ "蛔" : "hui",
+ "悔" : "hui",
+ "毁" : "hui",
+ "卉" : "hui",
+ "汇" : "hui",
+ "讳" : "hui",
+ "荟" : "hui",
+ "浍" : "hui",
+ "诲" : "hui",
+ "绘" : "hui",
+ "恚" : "hui",
+ "贿" : "hui",
+ "烩" : "hui",
+ "彗" : "hui",
+ "晦" : "hui",
+ "秽" : "hui",
+ "惠" : "hui",
+ "喙" : "hui",
+ "慧" : "hui",
+ "蕙" : "hui",
+ "蟪" : "hui",
+ "珲" : "hun",
+ "昏" : "hun",
+ "荤" : "hun",
+ "阍" : "hun",
+ "惛" : "hun",
+ "婚" : "hun",
+ "浑" : "hun",
+ "馄" : "hun",
+ "混" : "hun",
+ "魂" : "hun",
+ "诨" : "hun",
+ "溷" : "hun",
+ "耠" : "huo",
+ "劐" : "huo",
+ "豁" : "huo",
+ "活" : "huo",
+ "火" : "huo",
+ "伙" : "huo",
+ "钬" : "huo",
+ "夥" : "huo",
+ "或" : "huo",
+ "货" : "huo",
+ "获" : "huo",
+ "祸" : "huo",
+ "惑" : "huo",
+ "霍" : "huo",
+ "镬" : "huo",
+ "攉" : "huo",
+ "藿" : "huo",
+ "嚯" : "huo",
+ "讥" : "ji",
+ "击" : "ji",
+ "叽" : "ji",
+ "饥" : "ji",
+ "玑" : "ji",
+ "圾" : "ji",
+ "芨" : "ji",
+ "机" : "ji",
+ "乩" : "ji",
+ "肌" : "ji",
+ "矶" : "ji",
+ "鸡" : "ji",
+ "剞" : "ji",
+ "唧" : "ji",
+ "积" : "ji",
+ "笄" : "ji",
+ "屐" : "ji",
+ "姬" : "ji",
+ "基" : "ji",
+ "犄" : "ji",
+ "嵇" : "ji",
+ "畸" : "ji",
+ "跻" : "ji",
+ "箕" : "ji",
+ "齑" : "ji",
+ "畿" : "ji",
+ "墼" : "ji",
+ "激" : "ji",
+ "羁" : "ji",
+ "及" : "ji",
+ "吉" : "ji",
+ "岌" : "ji",
+ "汲" : "ji",
+ "级" : "ji",
+ "极" : "ji",
+ "即" : "ji",
+ "佶" : "ji",
+ "笈" : "ji",
+ "急" : "ji",
+ "疾" : "ji",
+ "棘" : "ji",
+ "集" : "ji",
+ "蒺" : "ji",
+ "楫" : "ji",
+ "辑" : "ji",
+ "嫉" : "ji",
+ "瘠" : "ji",
+ "藉" : "ji",
+ "籍" : "ji",
+ "几" : "ji",
+ "己" : "ji",
+ "虮" : "ji",
+ "挤" : "ji",
+ "脊" : "ji",
+ "掎" : "ji",
+ "戟" : "ji",
+ "麂" : "ji",
+ "计" : "ji",
+ "记" : "ji",
+ "伎" : "ji",
+ "纪" : "ji",
+ "技" : "ji",
+ "忌" : "ji",
+ "际" : "ji",
+ "妓" : "ji",
+ "季" : "ji",
+ "剂" : "ji",
+ "迹" : "ji",
+ "济" : "ji",
+ "既" : "ji",
+ "觊" : "ji",
+ "继" : "ji",
+ "偈" : "ji",
+ "祭" : "ji",
+ "悸" : "ji",
+ "寄" : "ji",
+ "寂" : "ji",
+ "绩" : "ji",
+ "暨" : "ji",
+ "稷" : "ji",
+ "鲫" : "ji",
+ "髻" : "ji",
+ "冀" : "ji",
+ "骥" : "ji",
+ "加" : "jia",
+ "佳" : "jia",
+ "枷" : "jia",
+ "浃" : "jia",
+ "痂" : "jia",
+ "家" : "jia",
+ "袈" : "jia",
+ "嘉" : "jia",
+ "镓" : "jia",
+ "荚" : "jia",
+ "戛" : "jia",
+ "颊" : "jia",
+ "甲" : "jia",
+ "胛" : "jia",
+ "钾" : "jia",
+ "假" : "jia",
+ "价" : "jia",
+ "驾" : "jia",
+ "架" : "jia",
+ "嫁" : "jia",
+ "稼" : "jia",
+ "戋" : "jian",
+ "尖" : "jian",
+ "奸" : "jian",
+ "歼" : "jian",
+ "坚" : "jian",
+ "间" : "jian",
+ "肩" : "jian",
+ "艰" : "jian",
+ "监" : "jian",
+ "兼" : "jian",
+ "菅" : "jian",
+ "笺" : "jian",
+ "缄" : "jian",
+ "煎" : "jian",
+ "拣" : "jian",
+ "茧" : "jian",
+ "柬" : "jian",
+ "俭" : "jian",
+ "捡" : "jian",
+ "检" : "jian",
+ "减" : "jian",
+ "剪" : "jian",
+ "睑" : "jian",
+ "简" : "jian",
+ "碱" : "jian",
+ "见" : "jian",
+ "件" : "jian",
+ "饯" : "jian",
+ "建" : "jian",
+ "荐" : "jian",
+ "贱" : "jian",
+ "剑" : "jian",
+ "健" : "jian",
+ "舰" : "jian",
+ "涧" : "jian",
+ "渐" : "jian",
+ "谏" : "jian",
+ "践" : "jian",
+ "锏" : "jian",
+ "毽" : "jian",
+ "腱" : "jian",
+ "溅" : "jian",
+ "鉴" : "jian",
+ "键" : "jian",
+ "僭" : "jian",
+ "箭" : "jian",
+ "江" : "jiang",
+ "将" : "jiang",
+ "姜" : "jiang",
+ "豇" : "jiang",
+ "浆" : "jiang",
+ "僵" : "jiang",
+ "缰" : "jiang",
+ "疆" : "jiang",
+ "讲" : "jiang",
+ "奖" : "jiang",
+ "桨" : "jiang",
+ "蒋" : "jiang",
+ "匠" : "jiang",
+ "酱" : "jiang",
+ "犟" : "jiang",
+ "糨" : "jiang",
+ "交" : "jiao",
+ "郊" : "jiao",
+ "浇" : "jiao",
+ "娇" : "jiao",
+ "姣" : "jiao",
+ "骄" : "jiao",
+ "胶" : "jiao",
+ "椒" : "jiao",
+ "蛟" : "jiao",
+ "焦" : "jiao",
+ "跤" : "jiao",
+ "蕉" : "jiao",
+ "礁" : "jiao",
+ "佼" : "jiao",
+ "狡" : "jiao",
+ "饺" : "jiao",
+ "绞" : "jiao",
+ "铰" : "jiao",
+ "矫" : "jiao",
+ "皎" : "jiao",
+ "脚" : "jiao",
+ "搅" : "jiao",
+ "剿" : "jiao",
+ "缴" : "jiao",
+ "叫" : "jiao",
+ "轿" : "jiao",
+ "较" : "jiao",
+ "教" : "jiao",
+ "窖" : "jiao",
+ "酵" : "jiao",
+ "侥" : "jiao",
+ "阶" : "jie",
+ "皆" : "jie",
+ "接" : "jie",
+ "秸" : "jie",
+ "揭" : "jie",
+ "嗟" : "jie",
+ "街" : "jie",
+ "孑" : "jie",
+ "节" : "jie",
+ "讦" : "jie",
+ "劫" : "jie",
+ "杰" : "jie",
+ "诘" : "jie",
+ "洁" : "jie",
+ "结" : "jie",
+ "捷" : "jie",
+ "睫" : "jie",
+ "截" : "jie",
+ "碣" : "jie",
+ "竭" : "jie",
+ "姐" : "jie",
+ "解" : "jie",
+ "介" : "jie",
+ "戒" : "jie",
+ "届" : "jie",
+ "界" : "jie",
+ "疥" : "jie",
+ "诫" : "jie",
+ "借" : "jie",
+ "巾" : "jin",
+ "斤" : "jin",
+ "今" : "jin",
+ "金" : "jin",
+ "津" : "jin",
+ "矜" : "jin",
+ "筋" : "jin",
+ "襟" : "jin",
+ "仅" : "jin",
+ "紧" : "jin",
+ "锦" : "jin",
+ "谨" : "jin",
+ "尽" : "jin",
+ "进" : "jin",
+ "近" : "jin",
+ "晋" : "jin",
+ "烬" : "jin",
+ "浸" : "jin",
+ "禁" : "jin",
+ "觐" : "jin",
+ "噤" : "jin",
+ "茎" : "jing",
+ "京" : "jing",
+ "泾" : "jing",
+ "经" : "jing",
+ "菁" : "jing",
+ "惊" : "jing",
+ "晶" : "jing",
+ "睛" : "jing",
+ "粳" : "jing",
+ "兢" : "jing",
+ "精" : "jing",
+ "鲸" : "jing",
+ "井" : "jing",
+ "阱" : "jing",
+ "刭" : "jing",
+ "景" : "jing",
+ "儆" : "jing",
+ "警" : "jing",
+ "径" : "jing",
+ "净" : "jing",
+ "痉" : "jing",
+ "竞" : "jing",
+ "竟" : "jing",
+ "敬" : "jing",
+ "靖" : "jing",
+ "静" : "jing",
+ "境" : "jing",
+ "镜" : "jing",
+ "迥" : "jiong",
+ "炯" : "jiong",
+ "窘" : "jiong",
+ "纠" : "jiu",
+ "鸠" : "jiu",
+ "究" : "jiu",
+ "赳" : "jiu",
+ "阄" : "jiu",
+ "揪" : "jiu",
+ "啾" : "jiu",
+ "九" : "jiu",
+ "久" : "jiu",
+ "玖" : "jiu",
+ "灸" : "jiu",
+ "韭" : "jiu",
+ "酒" : "jiu",
+ "旧" : "jiu",
+ "臼" : "jiu",
+ "咎" : "jiu",
+ "柩" : "jiu",
+ "救" : "jiu",
+ "厩" : "jiu",
+ "就" : "jiu",
+ "舅" : "jiu",
+ "鹫" : "jiu",
+ "军" : "jun",
+ "均" : "jun",
+ "君" : "jun",
+ "钧" : "jun",
+ "菌" : "jun",
+ "皲" : "jun",
+ "俊" : "jun",
+ "郡" : "jun",
+ "峻" : "jun",
+ "骏" : "jun",
+ "竣" : "jun",
+ "拘" : "ju",
+ "狙" : "ju",
+ "居" : "ju",
+ "驹" : "ju",
+ "掬" : "ju",
+ "雎" : "ju",
+ "鞠" : "ju",
+ "局" : "ju",
+ "菊" : "ju",
+ "焗" : "ju",
+ "橘" : "ju",
+ "咀" : "ju",
+ "沮" : "ju",
+ "矩" : "ju",
+ "举" : "ju",
+ "龃" : "ju",
+ "巨" : "ju",
+ "拒" : "ju",
+ "具" : "ju",
+ "炬" : "ju",
+ "俱" : "ju",
+ "剧" : "ju",
+ "据" : "ju",
+ "距" : "ju",
+ "惧" : "ju",
+ "飓" : "ju",
+ "锯" : "ju",
+ "聚" : "ju",
+ "踞" : "ju",
+ "捐" : "juan",
+ "涓" : "juan",
+ "娟" : "juan",
+ "鹃" : "juan",
+ "卷" : "juan",
+ "倦" : "juan",
+ "绢" : "juan",
+ "眷" : "juan",
+ "隽" : "juan",
+ "撅" : "jue",
+ "噘" : "jue",
+ "决" : "jue",
+ "诀" : "jue",
+ "抉" : "jue",
+ "绝" : "jue",
+ "掘" : "jue",
+ "崛" : "jue",
+ "厥" : "jue",
+ "谲" : "jue",
+ "蕨" : "jue",
+ "爵" : "jue",
+ "蹶" : "jue",
+ "矍" : "jue",
+ "倔" : "jue",
+ "咔" : "ka",
+ "开" : "kai",
+ "揩" : "kai",
+ "凯" : "kai",
+ "铠" : "kai",
+ "慨" : "kai",
+ "楷" : "kai",
+ "忾" : "kai",
+ "刊" : "kan",
+ "勘" : "kan",
+ "龛" : "kan",
+ "堪" : "kan",
+ "坎" : "kan",
+ "侃" : "kan",
+ "砍" : "kan",
+ "槛" : "kan",
+ "看" : "kan",
+ "瞰" : "kan",
+ "康" : "kang",
+ "慷" : "kang",
+ "糠" : "kang",
+ "亢" : "kang",
+ "伉" : "kang",
+ "抗" : "kang",
+ "炕" : "kang",
+ "考" : "kao",
+ "拷" : "kao",
+ "烤" : "kao",
+ "铐" : "kao",
+ "犒" : "kao",
+ "靠" : "kao",
+ "苛" : "ke",
+ "轲" : "ke",
+ "科" : "ke",
+ "棵" : "ke",
+ "搕" : "ke",
+ "嗑" : "ke",
+ "稞" : "ke",
+ "窠" : "ke",
+ "颗" : "ke",
+ "磕" : "ke",
+ "瞌" : "ke",
+ "蝌" : "ke",
+ "可" : "ke",
+ "坷" : "ke",
+ "渴" : "ke",
+ "克" : "ke",
+ "刻" : "ke",
+ "恪" : "ke",
+ "客" : "ke",
+ "课" : "ke",
+ "肯" : "ken",
+ "垦" : "ken",
+ "恳" : "ken",
+ "啃" : "ken",
+ "坑" : "keng",
+ "铿" : "keng",
+ "空" : "kong",
+ "孔" : "kong",
+ "恐" : "kong",
+ "控" : "kong",
+ "抠" : "kou",
+ "口" : "kou",
+ "叩" : "kou",
+ "扣" : "kou",
+ "寇" : "kou",
+ "蔻" : "kou",
+ "枯" : "ku",
+ "哭" : "ku",
+ "窟" : "ku",
+ "骷" : "ku",
+ "苦" : "ku",
+ "库" : "ku",
+ "绔" : "ku",
+ "裤" : "ku",
+ "酷" : "ku",
+ "夸" : "kua",
+ "垮" : "kua",
+ "挎" : "kua",
+ "胯" : "kua",
+ "跨" : "kua",
+ "块" : "kuai",
+ "快" : "kuai",
+ "侩" : "kuai",
+ "脍" : "kuai",
+ "筷" : "kuai",
+ "宽" : "kuan",
+ "髋" : "kuan",
+ "款" : "kuan",
+ "诓" : "kuang",
+ "哐" : "kuang",
+ "筐" : "kuang",
+ "狂" : "kuang",
+ "诳" : "kuang",
+ "旷" : "kuang",
+ "况" : "kuang",
+ "矿" : "kuang",
+ "框" : "kuang",
+ "眶" : "kuang",
+ "亏" : "kui",
+ "盔" : "kui",
+ "窥" : "kui",
+ "葵" : "kui",
+ "魁" : "kui",
+ "傀" : "kui",
+ "匮" : "kui",
+ "馈" : "kui",
+ "愧" : "kui",
+ "坤" : "kun",
+ "昆" : "kun",
+ "鲲" : "kun",
+ "捆" : "kun",
+ "困" : "kun",
+ "扩" : "kuo",
+ "括" : "kuo",
+ "阔" : "kuo",
+ "廓" : "kuo",
+ "垃" : "la",
+ "拉" : "la",
+ "啦" : "la",
+ "邋" : "la",
+ "旯" : "la",
+ "喇" : "la",
+ "腊" : "la",
+ "蜡" : "la",
+ "辣" : "la",
+ "来" : "lai",
+ "莱" : "lai",
+ "徕" : "lai",
+ "睐" : "lai",
+ "赖" : "lai",
+ "癞" : "lai",
+ "籁" : "lai",
+ "兰" : "lan",
+ "岚" : "lan",
+ "拦" : "lan",
+ "栏" : "lan",
+ "婪" : "lan",
+ "阑" : "lan",
+ "蓝" : "lan",
+ "澜" : "lan",
+ "褴" : "lan",
+ "篮" : "lan",
+ "览" : "lan",
+ "揽" : "lan",
+ "缆" : "lan",
+ "榄" : "lan",
+ "懒" : "lan",
+ "烂" : "lan",
+ "滥" : "lan",
+ "啷" : "lang",
+ "郎" : "lang",
+ "狼" : "lang",
+ "琅" : "lang",
+ "廊" : "lang",
+ "榔" : "lang",
+ "锒" : "lang",
+ "螂" : "lang",
+ "朗" : "lang",
+ "浪" : "lang",
+ "捞" : "lao",
+ "劳" : "lao",
+ "牢" : "lao",
+ "崂" : "lao",
+ "老" : "lao",
+ "佬" : "lao",
+ "姥" : "lao",
+ "唠" : "lao",
+ "烙" : "lao",
+ "涝" : "lao",
+ "酪" : "lao",
+ "雷" : "lei",
+ "羸" : "lei",
+ "垒" : "lei",
+ "磊" : "lei",
+ "蕾" : "lei",
+ "儡" : "lei",
+ "肋" : "lei",
+ "泪" : "lei",
+ "类" : "lei",
+ "累" : "lei",
+ "擂" : "lei",
+ "嘞" : "lei",
+ "棱" : "leng",
+ "楞" : "leng",
+ "冷" : "leng",
+ "睖" : "leng",
+ "厘" : "li",
+ "狸" : "li",
+ "离" : "li",
+ "梨" : "li",
+ "犁" : "li",
+ "鹂" : "li",
+ "喱" : "li",
+ "蜊" : "li",
+ "漓" : "li",
+ "璃" : "li",
+ "黎" : "li",
+ "罹" : "li",
+ "篱" : "li",
+ "蠡" : "li",
+ "礼" : "li",
+ "李" : "li",
+ "里" : "li",
+ "俚" : "li",
+ "逦" : "li",
+ "哩" : "li",
+ "娌" : "li",
+ "理" : "li",
+ "鲤" : "li",
+ "力" : "li",
+ "历" : "li",
+ "厉" : "li",
+ "立" : "li",
+ "吏" : "li",
+ "丽" : "li",
+ "励" : "li",
+ "呖" : "li",
+ "利" : "li",
+ "沥" : "li",
+ "枥" : "li",
+ "例" : "li",
+ "戾" : "li",
+ "隶" : "li",
+ "荔" : "li",
+ "俐" : "li",
+ "莉" : "li",
+ "莅" : "li",
+ "栗" : "li",
+ "砾" : "li",
+ "蛎" : "li",
+ "唳" : "li",
+ "笠" : "li",
+ "粒" : "li",
+ "雳" : "li",
+ "痢" : "li",
+ "连" : "lian",
+ "怜" : "lian",
+ "帘" : "lian",
+ "莲" : "lian",
+ "涟" : "lian",
+ "联" : "lian",
+ "廉" : "lian",
+ "鲢" : "lian",
+ "镰" : "lian",
+ "敛" : "lian",
+ "脸" : "lian",
+ "练" : "lian",
+ "炼" : "lian",
+ "恋" : "lian",
+ "殓" : "lian",
+ "链" : "lian",
+ "良" : "liang",
+ "凉" : "liang",
+ "梁" : "liang",
+ "粮" : "liang",
+ "粱" : "liang",
+ "两" : "liang",
+ "魉" : "liang",
+ "亮" : "liang",
+ "谅" : "liang",
+ "辆" : "liang",
+ "靓" : "liang",
+ "量" : "liang",
+ "晾" : "liang",
+ "踉" : "liang",
+ "辽" : "liao",
+ "疗" : "liao",
+ "聊" : "liao",
+ "僚" : "liao",
+ "寥" : "liao",
+ "撩" : "liao",
+ "嘹" : "liao",
+ "獠" : "liao",
+ "潦" : "liao",
+ "缭" : "liao",
+ "燎" : "liao",
+ "料" : "liao",
+ "撂" : "liao",
+ "瞭" : "liao",
+ "镣" : "liao",
+ "咧" : "lie",
+ "列" : "lie",
+ "劣" : "lie",
+ "冽" : "lie",
+ "烈" : "lie",
+ "猎" : "lie",
+ "裂" : "lie",
+ "趔" : "lie",
+ "拎" : "lin",
+ "邻" : "lin",
+ "林" : "lin",
+ "临" : "lin",
+ "淋" : "lin",
+ "琳" : "lin",
+ "粼" : "lin",
+ "嶙" : "lin",
+ "潾" : "lin",
+ "霖" : "lin",
+ "磷" : "lin",
+ "鳞" : "lin",
+ "麟" : "lin",
+ "凛" : "lin",
+ "檩" : "lin",
+ "吝" : "lin",
+ "赁" : "lin",
+ "躏" : "lin",
+ "伶" : "ling",
+ "灵" : "ling",
+ "苓" : "ling",
+ "囹" : "ling",
+ "泠" : "ling",
+ "玲" : "ling",
+ "瓴" : "ling",
+ "铃" : "ling",
+ "凌" : "ling",
+ "陵" : "ling",
+ "聆" : "ling",
+ "菱" : "ling",
+ "棂" : "ling",
+ "蛉" : "ling",
+ "翎" : "ling",
+ "羚" : "ling",
+ "绫" : "ling",
+ "零" : "ling",
+ "龄" : "ling",
+ "岭" : "ling",
+ "领" : "ling",
+ "另" : "ling",
+ "令" : "ling",
+ "溜" : "liu",
+ "熘" : "liu",
+ "刘" : "liu",
+ "浏" : "liu",
+ "留" : "liu",
+ "流" : "liu",
+ "琉" : "liu",
+ "硫" : "liu",
+ "馏" : "liu",
+ "榴" : "liu",
+ "瘤" : "liu",
+ "柳" : "liu",
+ "绺" : "liu",
+ "六" : "liu",
+ "遛" : "liu",
+ "龙" : "long",
+ "咙" : "long",
+ "珑" : "long",
+ "胧" : "long",
+ "聋" : "long",
+ "笼" : "long",
+ "隆" : "long",
+ "窿" : "long",
+ "陇" : "long",
+ "拢" : "long",
+ "垄" : "long",
+ "娄" : "lou",
+ "楼" : "lou",
+ "髅" : "lou",
+ "搂" : "lou",
+ "篓" : "lou",
+ "陋" : "lou",
+ "镂" : "lou",
+ "漏" : "lou",
+ "喽" : "lou",
+ "撸" : "lu",
+ "卢" : "lu",
+ "芦" : "lu",
+ "庐" : "lu",
+ "炉" : "lu",
+ "泸" : "lu",
+ "鸬" : "lu",
+ "颅" : "lu",
+ "鲈" : "lu",
+ "卤" : "lu",
+ "虏" : "lu",
+ "掳" : "lu",
+ "鲁" : "lu",
+ "橹" : "lu",
+ "录" : "lu",
+ "赂" : "lu",
+ "鹿" : "lu",
+ "禄" : "lu",
+ "路" : "lu",
+ "箓" : "lu",
+ "漉" : "lu",
+ "戮" : "lu",
+ "鹭" : "lu",
+ "麓" : "lu",
+ "峦" : "luan",
+ "孪" : "luan",
+ "挛" : "luan",
+ "鸾" : "luan",
+ "卵" : "luan",
+ "乱" : "luan",
+ "抡" : "lun",
+ "仑" : "lun",
+ "伦" : "lun",
+ "囵" : "lun",
+ "沦" : "lun",
+ "轮" : "lun",
+ "论" : "lun",
+ "啰" : "luo",
+ "罗" : "luo",
+ "萝" : "luo",
+ "逻" : "luo",
+ "锣" : "luo",
+ "箩" : "luo",
+ "骡" : "luo",
+ "螺" : "luo",
+ "裸" : "luo",
+ "洛" : "luo",
+ "络" : "luo",
+ "骆" : "luo",
+ "摞" : "luo",
+ "漯" : "luo",
+ "驴" : "lv",
+ "榈" : "lv",
+ "吕" : "lv",
+ "侣" : "lv",
+ "旅" : "lv",
+ "铝" : "lv",
+ "屡" : "lv",
+ "缕" : "lv",
+ "膂" : "lv",
+ "褛" : "lv",
+ "履" : "lv",
+ "律" : "lv",
+ "虑" : "lv",
+ "氯" : "lv",
+ "滤" : "lv",
+ "掠" : "lve",
+ "略" : "lve",
+ "妈" : "ma",
+ "麻" : "ma",
+ "蟆" : "ma",
+ "马" : "ma",
+ "犸" : "ma",
+ "玛" : "ma",
+ "码" : "ma",
+ "蚂" : "ma",
+ "骂" : "ma",
+ "吗" : "ma",
+ "嘛" : "ma",
+ "霾" : "mai",
+ "买" : "mai",
+ "迈" : "mai",
+ "麦" : "mai",
+ "卖" : "mai",
+ "霡" : "mai",
+ "蛮" : "man",
+ "馒" : "man",
+ "瞒" : "man",
+ "满" : "man",
+ "曼" : "man",
+ "谩" : "man",
+ "幔" : "man",
+ "漫" : "man",
+ "慢" : "man",
+ "牤" : "mang",
+ "芒" : "mang",
+ "忙" : "mang",
+ "盲" : "mang",
+ "氓" : "mang",
+ "茫" : "mang",
+ "莽" : "mang",
+ "漭" : "mang",
+ "蟒" : "mang",
+ "猫" : "mao",
+ "毛" : "mao",
+ "矛" : "mao",
+ "茅" : "mao",
+ "牦" : "mao",
+ "锚" : "mao",
+ "髦" : "mao",
+ "蝥" : "mao",
+ "蟊" : "mao",
+ "冇" : "mao",
+ "卯" : "mao",
+ "铆" : "mao",
+ "茂" : "mao",
+ "冒" : "mao",
+ "贸" : "mao",
+ "袤" : "mao",
+ "帽" : "mao",
+ "貌" : "mao",
+ "玫" : "mei",
+ "枚" : "mei",
+ "眉" : "mei",
+ "莓" : "mei",
+ "梅" : "mei",
+ "媒" : "mei",
+ "楣" : "mei",
+ "煤" : "mei",
+ "酶" : "mei",
+ "霉" : "mei",
+ "每" : "mei",
+ "美" : "mei",
+ "镁" : "mei",
+ "妹" : "mei",
+ "昧" : "mei",
+ "袂" : "mei",
+ "寐" : "mei",
+ "媚" : "mei",
+ "魅" : "mei",
+ "门" : "men",
+ "扪" : "men",
+ "闷" : "men",
+ "焖" : "men",
+ "懑" : "men",
+ "们" : "men",
+ "虻" : "meng",
+ "萌" : "meng",
+ "蒙" : "meng",
+ "盟" : "meng",
+ "檬" : "meng",
+ "曚" : "meng",
+ "朦" : "meng",
+ "猛" : "meng",
+ "锰" : "meng",
+ "蜢" : "meng",
+ "懵" : "meng",
+ "孟" : "meng",
+ "梦" : "meng",
+ "咪" : "mi",
+ "眯" : "mi",
+ "弥" : "mi",
+ "迷" : "mi",
+ "猕" : "mi",
+ "谜" : "mi",
+ "醚" : "mi",
+ "糜" : "mi",
+ "麋" : "mi",
+ "靡" : "mi",
+ "米" : "mi",
+ "弭" : "mi",
+ "觅" : "mi",
+ "密" : "mi",
+ "幂" : "mi",
+ "谧" : "mi",
+ "蜜" : "mi",
+ "眠" : "mian",
+ "绵" : "mian",
+ "棉" : "mian",
+ "免" : "mian",
+ "勉" : "mian",
+ "娩" : "mian",
+ "冕" : "mian",
+ "渑" : "mian",
+ "湎" : "mian",
+ "缅" : "mian",
+ "腼" : "mian",
+ "面" : "mian",
+ "喵" : "miao",
+ "苗" : "miao",
+ "描" : "miao",
+ "瞄" : "miao",
+ "秒" : "miao",
+ "渺" : "miao",
+ "藐" : "miao",
+ "妙" : "miao",
+ "庙" : "miao",
+ "缥" : "miao",
+ "咩" : "mie",
+ "灭" : "mie",
+ "蔑" : "mie",
+ "篾" : "mie",
+ "乜" : "mie",
+ "民" : "min",
+ "皿" : "min",
+ "抿" : "min",
+ "泯" : "min",
+ "闽" : "min",
+ "悯" : "min",
+ "敏" : "min",
+ "名" : "ming",
+ "明" : "ming",
+ "鸣" : "ming",
+ "茗" : "ming",
+ "冥" : "ming",
+ "铭" : "ming",
+ "瞑" : "ming",
+ "螟" : "ming",
+ "酩" : "ming",
+ "命" : "ming",
+ "谬" : "miu",
+ "摸" : "mo",
+ "馍" : "mo",
+ "摹" : "mo",
+ "膜" : "mo",
+ "摩" : "mo",
+ "磨" : "mo",
+ "蘑" : "mo",
+ "魔" : "mo",
+ "末" : "mo",
+ "茉" : "mo",
+ "殁" : "mo",
+ "沫" : "mo",
+ "陌" : "mo",
+ "莫" : "mo",
+ "秣" : "mo",
+ "蓦" : "mo",
+ "漠" : "mo",
+ "寞" : "mo",
+ "墨" : "mo",
+ "默" : "mo",
+ "嬷" : "mo",
+ "缪" : "mou",
+ "哞" : "mou",
+ "眸" : "mou",
+ "谋" : "mou",
+ "某" : "mou",
+ "母" : "mu",
+ "牡" : "mu",
+ "亩" : "mu",
+ "拇" : "mu",
+ "姆" : "mu",
+ "木" : "mu",
+ "目" : "mu",
+ "沐" : "mu",
+ "苜" : "mu",
+ "牧" : "mu",
+ "钼" : "mu",
+ "募" : "mu",
+ "墓" : "mu",
+ "幕" : "mu",
+ "睦" : "mu",
+ "慕" : "mu",
+ "暮" : "mu",
+ "穆" : "mu",
+ "拿" : "na",
+ "呐" : "na",
+ "纳" : "na",
+ "钠" : "na",
+ "衲" : "na",
+ "捺" : "na",
+ "乃" : "nai",
+ "奶" : "nai",
+ "氖" : "nai",
+ "奈" : "nai",
+ "耐" : "nai",
+ "囡" : "nan",
+ "男" : "nan",
+ "南" : "nan",
+ "难" : "nan",
+ "喃" : "nan",
+ "楠" : "nan",
+ "赧" : "nan",
+ "腩" : "nan",
+ "囔" : "nang",
+ "囊" : "nang",
+ "孬" : "nao",
+ "呶" : "nao",
+ "挠" : "nao",
+ "恼" : "nao",
+ "脑" : "nao",
+ "瑙" : "nao",
+ "闹" : "nao",
+ "淖" : "nao",
+ "讷" : "ne",
+ "馁" : "nei",
+ "内" : "nei",
+ "嫩" : "nen",
+ "恁" : "nen",
+ "能" : "neng",
+ "嗯" : "ng",
+ "妮" : "ni",
+ "尼" : "ni",
+ "泥" : "ni",
+ "怩" : "ni",
+ "倪" : "ni",
+ "霓" : "ni",
+ "拟" : "ni",
+ "你" : "ni",
+ "旎" : "ni",
+ "昵" : "ni",
+ "逆" : "ni",
+ "匿" : "ni",
+ "腻" : "ni",
+ "溺" : "ni",
+ "拈" : "nian",
+ "蔫" : "nian",
+ "年" : "nian",
+ "黏" : "nian",
+ "捻" : "nian",
+ "辇" : "nian",
+ "撵" : "nian",
+ "碾" : "nian",
+ "廿" : "nian",
+ "念" : "nian",
+ "娘" : "niang",
+ "酿" : "niang",
+ "鸟" : "niao",
+ "袅" : "niao",
+ "尿" : "niao",
+ "捏" : "nie",
+ "聂" : "nie",
+ "涅" : "nie",
+ "嗫" : "nie",
+ "镊" : "nie",
+ "镍" : "nie",
+ "蹑" : "nie",
+ "孽" : "nie",
+ "您" : "nin",
+ "宁" : "ning",
+ "咛" : "ning",
+ "狞" : "ning",
+ "柠" : "ning",
+ "凝" : "ning",
+ "拧" : "ning",
+ "佞" : "ning",
+ "泞" : "ning",
+ "妞" : "niu",
+ "牛" : "niu",
+ "扭" : "niu",
+ "忸" : "niu",
+ "纽" : "niu",
+ "钮" : "niu",
+ "农" : "nong",
+ "哝" : "nong",
+ "浓" : "nong",
+ "脓" : "nong",
+ "弄" : "nong",
+ "奴" : "nu",
+ "驽" : "nu",
+ "努" : "nu",
+ "弩" : "nu",
+ "怒" : "nu",
+ "暖" : "nuan",
+ "疟" : "nue",
+ "虐" : "nue",
+ "挪" : "nuo",
+ "诺" : "nuo",
+ "喏" : "nuo",
+ "懦" : "nuo",
+ "糯" : "nuo",
+ "女" : "nv",
+ "噢" : "o",
+ "讴" : "ou",
+ "瓯" : "ou",
+ "欧" : "ou",
+ "殴" : "ou",
+ "鸥" : "ou",
+ "呕" : "ou",
+ "偶" : "ou",
+ "藕" : "ou",
+ "怄" : "ou",
+ "趴" : "pa",
+ "啪" : "pa",
+ "葩" : "pa",
+ "杷" : "pa",
+ "爬" : "pa",
+ "琶" : "pa",
+ "帕" : "pa",
+ "怕" : "pa",
+ "拍" : "pai",
+ "排" : "pai",
+ "徘" : "pai",
+ "牌" : "pai",
+ "哌" : "pai",
+ "派" : "pai",
+ "湃" : "pai",
+ "潘" : "pan",
+ "攀" : "pan",
+ "爿" : "pan",
+ "盘" : "pan",
+ "磐" : "pan",
+ "蹒" : "pan",
+ "蟠" : "pan",
+ "判" : "pan",
+ "盼" : "pan",
+ "叛" : "pan",
+ "畔" : "pan",
+ "乓" : "pang",
+ "滂" : "pang",
+ "庞" : "pang",
+ "旁" : "pang",
+ "螃" : "pang",
+ "耪" : "pang",
+ "抛" : "pao",
+ "咆" : "pao",
+ "庖" : "pao",
+ "袍" : "pao",
+ "跑" : "pao",
+ "泡" : "pao",
+ "呸" : "pei",
+ "胚" : "pei",
+ "陪" : "pei",
+ "培" : "pei",
+ "赔" : "pei",
+ "裴" : "pei",
+ "沛" : "pei",
+ "佩" : "pei",
+ "配" : "pei",
+ "喷" : "pen",
+ "盆" : "pen",
+ "抨" : "peng",
+ "怦" : "peng",
+ "砰" : "peng",
+ "烹" : "peng",
+ "嘭" : "peng",
+ "朋" : "peng",
+ "彭" : "peng",
+ "棚" : "peng",
+ "蓬" : "peng",
+ "硼" : "peng",
+ "鹏" : "peng",
+ "澎" : "peng",
+ "篷" : "peng",
+ "膨" : "peng",
+ "捧" : "peng",
+ "碰" : "peng",
+ "丕" : "pi",
+ "批" : "pi",
+ "纰" : "pi",
+ "坯" : "pi",
+ "披" : "pi",
+ "砒" : "pi",
+ "劈" : "pi",
+ "噼" : "pi",
+ "霹" : "pi",
+ "皮" : "pi",
+ "枇" : "pi",
+ "毗" : "pi",
+ "蚍" : "pi",
+ "疲" : "pi",
+ "啤" : "pi",
+ "琵" : "pi",
+ "脾" : "pi",
+ "貔" : "pi",
+ "匹" : "pi",
+ "痞" : "pi",
+ "癖" : "pi",
+ "屁" : "pi",
+ "睥" : "pi",
+ "媲" : "pi",
+ "僻" : "pi",
+ "譬" : "pi",
+ "偏" : "pian",
+ "篇" : "pian",
+ "翩" : "pian",
+ "骈" : "pian",
+ "蹁" : "pian",
+ "片" : "pian",
+ "骗" : "pian",
+ "剽" : "piao",
+ "漂" : "piao",
+ "飘" : "piao",
+ "瓢" : "piao",
+ "殍" : "piao",
+ "瞟" : "piao",
+ "票" : "piao",
+ "氕" : "pie",
+ "瞥" : "pie",
+ "撇" : "pie",
+ "拼" : "pin",
+ "姘" : "pin",
+ "贫" : "pin",
+ "频" : "pin",
+ "嫔" : "pin",
+ "颦" : "pin",
+ "品" : "pin",
+ "聘" : "pin",
+ "乒" : "ping",
+ "娉" : "ping",
+ "平" : "ping",
+ "评" : "ping",
+ "坪" : "ping",
+ "苹" : "ping",
+ "凭" : "ping",
+ "瓶" : "ping",
+ "萍" : "ping",
+ "钋" : "po",
+ "坡" : "po",
+ "泼" : "po",
+ "颇" : "po",
+ "婆" : "po",
+ "鄱" : "po",
+ "叵" : "po",
+ "珀" : "po",
+ "破" : "po",
+ "粕" : "po",
+ "魄" : "po",
+ "剖" : "pou",
+ "抔" : "pou",
+ "扑" : "pu",
+ "铺" : "pu",
+ "噗" : "pu",
+ "仆" : "pu",
+ "匍" : "pu",
+ "菩" : "pu",
+ "葡" : "pu",
+ "蒲" : "pu",
+ "璞" : "pu",
+ "圃" : "pu",
+ "浦" : "pu",
+ "普" : "pu",
+ "谱" : "pu",
+ "蹼" : "pu",
+ "七" : "qi",
+ "沏" : "qi",
+ "妻" : "qi",
+ "柒" : "qi",
+ "凄" : "qi",
+ "萋" : "qi",
+ "戚" : "qi",
+ "期" : "qi",
+ "欺" : "qi",
+ "嘁" : "qi",
+ "漆" : "qi",
+ "齐" : "qi",
+ "芪" : "qi",
+ "其" : "qi",
+ "歧" : "qi",
+ "祈" : "qi",
+ "祇" : "qi",
+ "脐" : "qi",
+ "畦" : "qi",
+ "跂" : "qi",
+ "崎" : "qi",
+ "骑" : "qi",
+ "琪" : "qi",
+ "棋" : "qi",
+ "旗" : "qi",
+ "鳍" : "qi",
+ "麒" : "qi",
+ "乞" : "qi",
+ "岂" : "qi",
+ "企" : "qi",
+ "杞" : "qi",
+ "启" : "qi",
+ "起" : "qi",
+ "绮" : "qi",
+ "气" : "qi",
+ "讫" : "qi",
+ "迄" : "qi",
+ "弃" : "qi",
+ "汽" : "qi",
+ "泣" : "qi",
+ "契" : "qi",
+ "砌" : "qi",
+ "葺" : "qi",
+ "器" : "qi",
+ "憩" : "qi",
+ "俟" : "qi",
+ "掐" : "qia",
+ "洽" : "qia",
+ "恰" : "qia",
+ "千" : "qian",
+ "仟" : "qian",
+ "阡" : "qian",
+ "芊" : "qian",
+ "迁" : "qian",
+ "钎" : "qian",
+ "牵" : "qian",
+ "悭" : "qian",
+ "谦" : "qian",
+ "签" : "qian",
+ "愆" : "qian",
+ "前" : "qian",
+ "虔" : "qian",
+ "钱" : "qian",
+ "钳" : "qian",
+ "乾" : "qian",
+ "潜" : "qian",
+ "黔" : "qian",
+ "遣" : "qian",
+ "谴" : "qian",
+ "欠" : "qian",
+ "芡" : "qian",
+ "倩" : "qian",
+ "堑" : "qian",
+ "嵌" : "qian",
+ "歉" : "qian",
+ "羌" : "qiang",
+ "枪" : "qiang",
+ "戕" : "qiang",
+ "腔" : "qiang",
+ "蜣" : "qiang",
+ "锵" : "qiang",
+ "墙" : "qiang",
+ "蔷" : "qiang",
+ "抢" : "qiang",
+ "羟" : "qiang",
+ "襁" : "qiang",
+ "呛" : "qiang",
+ "炝" : "qiang",
+ "跄" : "qiang",
+ "悄" : "qiao",
+ "跷" : "qiao",
+ "锹" : "qiao",
+ "敲" : "qiao",
+ "橇" : "qiao",
+ "乔" : "qiao",
+ "侨" : "qiao",
+ "荞" : "qiao",
+ "桥" : "qiao",
+ "憔" : "qiao",
+ "瞧" : "qiao",
+ "巧" : "qiao",
+ "俏" : "qiao",
+ "诮" : "qiao",
+ "峭" : "qiao",
+ "窍" : "qiao",
+ "翘" : "qiao",
+ "撬" : "qiao",
+ "切" : "qie",
+ "且" : "qie",
+ "妾" : "qie",
+ "怯" : "qie",
+ "窃" : "qie",
+ "挈" : "qie",
+ "惬" : "qie",
+ "趄" : "qie",
+ "锲" : "qie",
+ "钦" : "qin",
+ "侵" : "qin",
+ "衾" : "qin",
+ "芹" : "qin",
+ "芩" : "qin",
+ "秦" : "qin",
+ "琴" : "qin",
+ "禽" : "qin",
+ "勤" : "qin",
+ "擒" : "qin",
+ "噙" : "qin",
+ "寝" : "qin",
+ "沁" : "qin",
+ "青" : "qing",
+ "轻" : "qing",
+ "氢" : "qing",
+ "倾" : "qing",
+ "卿" : "qing",
+ "清" : "qing",
+ "蜻" : "qing",
+ "情" : "qing",
+ "晴" : "qing",
+ "氰" : "qing",
+ "擎" : "qing",
+ "顷" : "qing",
+ "请" : "qing",
+ "庆" : "qing",
+ "罄" : "qing",
+ "穷" : "qiong",
+ "穹" : "qiong",
+ "琼" : "qiong",
+ "丘" : "qiu",
+ "秋" : "qiu",
+ "蚯" : "qiu",
+ "鳅" : "qiu",
+ "囚" : "qiu",
+ "求" : "qiu",
+ "虬" : "qiu",
+ "泅" : "qiu",
+ "酋" : "qiu",
+ "球" : "qiu",
+ "遒" : "qiu",
+ "裘" : "qiu",
+ "岖" : "qu",
+ "驱" : "qu",
+ "屈" : "qu",
+ "蛆" : "qu",
+ "躯" : "qu",
+ "趋" : "qu",
+ "蛐" : "qu",
+ "黢" : "qu",
+ "渠" : "qu",
+ "瞿" : "qu",
+ "曲" : "qu",
+ "取" : "qu",
+ "娶" : "qu",
+ "龋" : "qu",
+ "去" : "qu",
+ "趣" : "qu",
+ "觑" : "qu",
+ "悛" : "quan",
+ "权" : "quan",
+ "全" : "quan",
+ "诠" : "quan",
+ "泉" : "quan",
+ "拳" : "quan",
+ "痊" : "quan",
+ "蜷" : "quan",
+ "醛" : "quan",
+ "犬" : "quan",
+ "劝" : "quan",
+ "券" : "quan",
+ "炔" : "que",
+ "缺" : "que",
+ "瘸" : "que",
+ "却" : "que",
+ "确" : "que",
+ "鹊" : "que",
+ "阙" : "que",
+ "榷" : "que",
+ "逡" : "qun",
+ "裙" : "qun",
+ "群" : "qun",
+ "蚺" : "ran",
+ "然" : "ran",
+ "燃" : "ran",
+ "冉" : "ran",
+ "苒" : "ran",
+ "染" : "ran",
+ "瓤" : "rang",
+ "壤" : "rang",
+ "攘" : "rang",
+ "嚷" : "rang",
+ "让" : "rang",
+ "荛" : "rao",
+ "饶" : "rao",
+ "娆" : "rao",
+ "桡" : "rao",
+ "扰" : "rao",
+ "绕" : "rao",
+ "惹" : "re",
+ "热" : "re",
+ "人" : "ren",
+ "壬" : "ren",
+ "仁" : "ren",
+ "忍" : "ren",
+ "荏" : "ren",
+ "稔" : "ren",
+ "刃" : "ren",
+ "认" : "ren",
+ "任" : "ren",
+ "纫" : "ren",
+ "韧" : "ren",
+ "饪" : "ren",
+ "扔" : "reng",
+ "仍" : "reng",
+ "日" : "ri",
+ "戎" : "rong",
+ "茸" : "rong",
+ "荣" : "rong",
+ "绒" : "rong",
+ "容" : "rong",
+ "嵘" : "rong",
+ "蓉" : "rong",
+ "溶" : "rong",
+ "榕" : "rong",
+ "熔" : "rong",
+ "融" : "rong",
+ "冗" : "rong",
+ "氄" : "rong",
+ "柔" : "rou",
+ "揉" : "rou",
+ "糅" : "rou",
+ "蹂" : "rou",
+ "鞣" : "rou",
+ "肉" : "rou",
+ "如" : "ru",
+ "茹" : "ru",
+ "铷" : "ru",
+ "儒" : "ru",
+ "孺" : "ru",
+ "蠕" : "ru",
+ "汝" : "ru",
+ "乳" : "ru",
+ "辱" : "ru",
+ "入" : "ru",
+ "缛" : "ru",
+ "褥" : "ru",
+ "阮" : "ruan",
+ "软" : "ruan",
+ "蕊" : "rui",
+ "蚋" : "rui",
+ "锐" : "rui",
+ "瑞" : "rui",
+ "睿" : "rui",
+ "闰" : "run",
+ "润" : "run",
+ "若" : "ruo",
+ "偌" : "ruo",
+ "弱" : "ruo",
+ "仨" : "sa",
+ "洒" : "sa",
+ "撒" : "sa",
+ "卅" : "sa",
+ "飒" : "sa",
+ "萨" : "sa",
+ "腮" : "sai",
+ "赛" : "sai",
+ "三" : "san",
+ "叁" : "san",
+ "伞" : "san",
+ "散" : "san",
+ "桑" : "sang",
+ "搡" : "sang",
+ "嗓" : "sang",
+ "丧" : "sang",
+ "搔" : "sao",
+ "骚" : "sao",
+ "扫" : "sao",
+ "嫂" : "sao",
+ "臊" : "sao",
+ "涩" : "se",
+ "啬" : "se",
+ "铯" : "se",
+ "瑟" : "se",
+ "穑" : "se",
+ "森" : "sen",
+ "僧" : "seng",
+ "杀" : "sha",
+ "沙" : "sha",
+ "纱" : "sha",
+ "砂" : "sha",
+ "啥" : "sha",
+ "傻" : "sha",
+ "厦" : "sha",
+ "歃" : "sha",
+ "煞" : "sha",
+ "霎" : "sha",
+ "筛" : "shai",
+ "晒" : "shai",
+ "山" : "shan",
+ "删" : "shan",
+ "苫" : "shan",
+ "衫" : "shan",
+ "姗" : "shan",
+ "珊" : "shan",
+ "煽" : "shan",
+ "潸" : "shan",
+ "膻" : "shan",
+ "闪" : "shan",
+ "陕" : "shan",
+ "讪" : "shan",
+ "汕" : "shan",
+ "扇" : "shan",
+ "善" : "shan",
+ "骟" : "shan",
+ "缮" : "shan",
+ "擅" : "shan",
+ "膳" : "shan",
+ "嬗" : "shan",
+ "赡" : "shan",
+ "鳝" : "shan",
+ "伤" : "shang",
+ "殇" : "shang",
+ "商" : "shang",
+ "觞" : "shang",
+ "熵" : "shang",
+ "晌" : "shang",
+ "赏" : "shang",
+ "上" : "shang",
+ "尚" : "shang",
+ "捎" : "shao",
+ "烧" : "shao",
+ "梢" : "shao",
+ "稍" : "shao",
+ "艄" : "shao",
+ "勺" : "shao",
+ "芍" : "shao",
+ "韶" : "shao",
+ "少" : "shao",
+ "邵" : "shao",
+ "绍" : "shao",
+ "哨" : "shao",
+ "潲" : "shao",
+ "奢" : "she",
+ "赊" : "she",
+ "舌" : "she",
+ "佘" : "she",
+ "蛇" : "she",
+ "舍" : "she",
+ "设" : "she",
+ "社" : "she",
+ "射" : "she",
+ "涉" : "she",
+ "赦" : "she",
+ "摄" : "she",
+ "慑" : "she",
+ "麝" : "she",
+ "申" : "shen",
+ "伸" : "shen",
+ "身" : "shen",
+ "呻" : "shen",
+ "绅" : "shen",
+ "砷" : "shen",
+ "深" : "shen",
+ "神" : "shen",
+ "沈" : "shen",
+ "审" : "shen",
+ "哂" : "shen",
+ "婶" : "shen",
+ "肾" : "shen",
+ "甚" : "shen",
+ "渗" : "shen",
+ "葚" : "shen",
+ "蜃" : "shen",
+ "慎" : "shen",
+ "升" : "sheng",
+ "生" : "sheng",
+ "声" : "sheng",
+ "昇" : "sheng",
+ "牲" : "sheng",
+ "笙" : "sheng",
+ "甥" : "sheng",
+ "绳" : "sheng",
+ "圣" : "sheng",
+ "胜" : "sheng",
+ "晟" : "sheng",
+ "剩" : "sheng",
+ "尸" : "shi",
+ "失" : "shi",
+ "师" : "shi",
+ "诗" : "shi",
+ "虱" : "shi",
+ "狮" : "shi",
+ "施" : "shi",
+ "湿" : "shi",
+ "十" : "shi",
+ "时" : "shi",
+ "实" : "shi",
+ "食" : "shi",
+ "蚀" : "shi",
+ "史" : "shi",
+ "矢" : "shi",
+ "使" : "shi",
+ "始" : "shi",
+ "驶" : "shi",
+ "屎" : "shi",
+ "士" : "shi",
+ "氏" : "shi",
+ "示" : "shi",
+ "世" : "shi",
+ "仕" : "shi",
+ "市" : "shi",
+ "式" : "shi",
+ "势" : "shi",
+ "事" : "shi",
+ "侍" : "shi",
+ "饰" : "shi",
+ "试" : "shi",
+ "视" : "shi",
+ "拭" : "shi",
+ "柿" : "shi",
+ "是" : "shi",
+ "适" : "shi",
+ "恃" : "shi",
+ "室" : "shi",
+ "逝" : "shi",
+ "轼" : "shi",
+ "舐" : "shi",
+ "弑" : "shi",
+ "释" : "shi",
+ "谥" : "shi",
+ "嗜" : "shi",
+ "誓" : "shi",
+ "收" : "shou",
+ "手" : "shou",
+ "守" : "shou",
+ "首" : "shou",
+ "寿" : "shou",
+ "受" : "shou",
+ "狩" : "shou",
+ "授" : "shou",
+ "售" : "shou",
+ "兽" : "shou",
+ "绶" : "shou",
+ "瘦" : "shou",
+ "殳" : "shu",
+ "书" : "shu",
+ "抒" : "shu",
+ "枢" : "shu",
+ "叔" : "shu",
+ "姝" : "shu",
+ "殊" : "shu",
+ "倏" : "shu",
+ "梳" : "shu",
+ "淑" : "shu",
+ "舒" : "shu",
+ "疏" : "shu",
+ "输" : "shu",
+ "蔬" : "shu",
+ "秫" : "shu",
+ "孰" : "shu",
+ "赎" : "shu",
+ "塾" : "shu",
+ "暑" : "shu",
+ "黍" : "shu",
+ "署" : "shu",
+ "蜀" : "shu",
+ "鼠" : "shu",
+ "薯" : "shu",
+ "曙" : "shu",
+ "戍" : "shu",
+ "束" : "shu",
+ "述" : "shu",
+ "树" : "shu",
+ "竖" : "shu",
+ "恕" : "shu",
+ "庶" : "shu",
+ "墅" : "shu",
+ "漱" : "shu",
+ "刷" : "shua",
+ "唰" : "shua",
+ "耍" : "shua",
+ "衰" : "shuai",
+ "摔" : "shuai",
+ "甩" : "shuai",
+ "帅" : "shuai",
+ "蟀" : "shuai",
+ "闩" : "shuan",
+ "拴" : "shuan",
+ "栓" : "shuan",
+ "涮" : "shuan",
+ "双" : "shuang",
+ "霜" : "shuang",
+ "孀" : "shuang",
+ "爽" : "shuang",
+ "谁" : "shui",
+ "水" : "shui",
+ "税" : "shui",
+ "睡" : "shui",
+ "吮" : "shun",
+ "顺" : "shun",
+ "舜" : "shun",
+ "瞬" : "shun",
+ "烁" : "shuo",
+ "铄" : "shuo",
+ "朔" : "shuo",
+ "硕" : "shuo",
+ "司" : "si",
+ "丝" : "si",
+ "私" : "si",
+ "咝" : "si",
+ "思" : "si",
+ "斯" : "si",
+ "厮" : "si",
+ "撕" : "si",
+ "嘶" : "si",
+ "死" : "si",
+ "巳" : "si",
+ "四" : "si",
+ "寺" : "si",
+ "祀" : "si",
+ "饲" : "si",
+ "肆" : "si",
+ "嗣" : "si",
+ "松" : "song",
+ "嵩" : "song",
+ "怂" : "song",
+ "耸" : "song",
+ "悚" : "song",
+ "讼" : "song",
+ "宋" : "song",
+ "送" : "song",
+ "诵" : "song",
+ "颂" : "song",
+ "搜" : "sou",
+ "嗖" : "sou",
+ "馊" : "sou",
+ "艘" : "sou",
+ "叟" : "sou",
+ "擞" : "sou",
+ "嗽" : "sou",
+ "苏" : "su",
+ "酥" : "su",
+ "俗" : "su",
+ "夙" : "su",
+ "诉" : "su",
+ "肃" : "su",
+ "素" : "su",
+ "速" : "su",
+ "粟" : "su",
+ "嗉" : "su",
+ "塑" : "su",
+ "溯" : "su",
+ "簌" : "su",
+ "酸" : "suan",
+ "蒜" : "suan",
+ "算" : "suan",
+ "虽" : "sui",
+ "睢" : "sui",
+ "绥" : "sui",
+ "隋" : "sui",
+ "随" : "sui",
+ "髓" : "sui",
+ "岁" : "sui",
+ "祟" : "sui",
+ "遂" : "sui",
+ "碎" : "sui",
+ "隧" : "sui",
+ "穗" : "sui",
+ "孙" : "sun",
+ "损" : "sun",
+ "笋" : "sun",
+ "隼" : "sun",
+ "唆" : "suo",
+ "梭" : "suo",
+ "蓑" : "suo",
+ "羧" : "suo",
+ "缩" : "suo",
+ "所" : "suo",
+ "索" : "suo",
+ "唢" : "suo",
+ "琐" : "suo",
+ "锁" : "suo",
+ "他" : "ta",
+ "它" : "ta",
+ "她" : "ta",
+ "铊" : "ta",
+ "塌" : "ta",
+ "塔" : "ta",
+ "獭" : "ta",
+ "挞" : "ta",
+ "榻" : "ta",
+ "踏" : "ta",
+ "蹋" : "ta",
+ "胎" : "tai",
+ "台" : "tai",
+ "邰" : "tai",
+ "抬" : "tai",
+ "苔" : "tai",
+ "跆" : "tai",
+ "太" : "tai",
+ "汰" : "tai",
+ "态" : "tai",
+ "钛" : "tai",
+ "泰" : "tai",
+ "酞" : "tai",
+ "贪" : "tan",
+ "摊" : "tan",
+ "滩" : "tan",
+ "瘫" : "tan",
+ "坛" : "tan",
+ "昙" : "tan",
+ "谈" : "tan",
+ "痰" : "tan",
+ "谭" : "tan",
+ "潭" : "tan",
+ "檀" : "tan",
+ "坦" : "tan",
+ "袒" : "tan",
+ "毯" : "tan",
+ "叹" : "tan",
+ "炭" : "tan",
+ "探" : "tan",
+ "碳" : "tan",
+ "汤" : "tang",
+ "嘡" : "tang",
+ "羰" : "tang",
+ "唐" : "tang",
+ "堂" : "tang",
+ "棠" : "tang",
+ "塘" : "tang",
+ "搪" : "tang",
+ "膛" : "tang",
+ "镗" : "tang",
+ "糖" : "tang",
+ "螳" : "tang",
+ "倘" : "tang",
+ "淌" : "tang",
+ "躺" : "tang",
+ "烫" : "tang",
+ "趟" : "tang",
+ "涛" : "tao",
+ "绦" : "tao",
+ "掏" : "tao",
+ "滔" : "tao",
+ "韬" : "tao",
+ "饕" : "tao",
+ "逃" : "tao",
+ "桃" : "tao",
+ "陶" : "tao",
+ "萄" : "tao",
+ "淘" : "tao",
+ "讨" : "tao",
+ "套" : "tao",
+ "特" : "te",
+ "疼" : "teng",
+ "腾" : "teng",
+ "誊" : "teng",
+ "滕" : "teng",
+ "藤" : "teng",
+ "剔" : "ti",
+ "梯" : "ti",
+ "踢" : "ti",
+ "啼" : "ti",
+ "题" : "ti",
+ "醍" : "ti",
+ "蹄" : "ti",
+ "体" : "ti",
+ "屉" : "ti",
+ "剃" : "ti",
+ "涕" : "ti",
+ "悌" : "ti",
+ "惕" : "ti",
+ "替" : "ti",
+ "天" : "tian",
+ "添" : "tian",
+ "田" : "tian",
+ "恬" : "tian",
+ "甜" : "tian",
+ "填" : "tian",
+ "忝" : "tian",
+ "殄" : "tian",
+ "舔" : "tian",
+ "掭" : "tian",
+ "佻" : "tiao",
+ "挑" : "tiao",
+ "条" : "tiao",
+ "迢" : "tiao",
+ "笤" : "tiao",
+ "髫" : "tiao",
+ "窕" : "tiao",
+ "眺" : "tiao",
+ "粜" : "tiao",
+ "跳" : "tiao",
+ "帖" : "tie",
+ "贴" : "tie",
+ "铁" : "tie",
+ "餮" : "tie",
+ "铤" : "ting",
+ "厅" : "ting",
+ "听" : "ting",
+ "烃" : "ting",
+ "廷" : "ting",
+ "亭" : "ting",
+ "庭" : "ting",
+ "停" : "ting",
+ "蜓" : "ting",
+ "婷" : "ting",
+ "霆" : "ting",
+ "挺" : "ting",
+ "艇" : "ting",
+ "通" : "tong",
+ "嗵" : "tong",
+ "同" : "tong",
+ "彤" : "tong",
+ "桐" : "tong",
+ "铜" : "tong",
+ "童" : "tong",
+ "潼" : "tong",
+ "瞳" : "tong",
+ "统" : "tong",
+ "捅" : "tong",
+ "桶" : "tong",
+ "筒" : "tong",
+ "恸" : "tong",
+ "痛" : "tong",
+ "偷" : "tou",
+ "头" : "tou",
+ "投" : "tou",
+ "骰" : "tou",
+ "透" : "tou",
+ "凸" : "tu",
+ "秃" : "tu",
+ "突" : "tu",
+ "图" : "tu",
+ "荼" : "tu",
+ "徒" : "tu",
+ "途" : "tu",
+ "涂" : "tu",
+ "屠" : "tu",
+ "土" : "tu",
+ "吐" : "tu",
+ "兔" : "tu",
+ "菟" : "tu",
+ "湍" : "tuan",
+ "团" : "tuan",
+ "疃" : "tuan",
+ "彖" : "tuan",
+ "推" : "tui",
+ "颓" : "tui",
+ "腿" : "tui",
+ "退" : "tui",
+ "蜕" : "tui",
+ "褪" : "tui",
+ "吞" : "tun",
+ "屯" : "tun",
+ "饨" : "tun",
+ "豚" : "tun",
+ "臀" : "tun",
+ "托" : "tuo",
+ "拖" : "tuo",
+ "脱" : "tuo",
+ "佗" : "tuo",
+ "陀" : "tuo",
+ "驼" : "tuo",
+ "鸵" : "tuo",
+ "妥" : "tuo",
+ "椭" : "tuo",
+ "唾" : "tuo",
+ "挖" : "wa",
+ "哇" : "wa",
+ "洼" : "wa",
+ "娲" : "wa",
+ "蛙" : "wa",
+ "娃" : "wa",
+ "瓦" : "wa",
+ "佤" : "wa",
+ "袜" : "wa",
+ "歪" : "wai",
+ "外" : "wai",
+ "弯" : "wan",
+ "剜" : "wan",
+ "湾" : "wan",
+ "蜿" : "wan",
+ "豌" : "wan",
+ "丸" : "wan",
+ "纨" : "wan",
+ "完" : "wan",
+ "玩" : "wan",
+ "顽" : "wan",
+ "烷" : "wan",
+ "宛" : "wan",
+ "挽" : "wan",
+ "晚" : "wan",
+ "惋" : "wan",
+ "婉" : "wan",
+ "绾" : "wan",
+ "皖" : "wan",
+ "碗" : "wan",
+ "万" : "wan",
+ "腕" : "wan",
+ "汪" : "wang",
+ "亡" : "wang",
+ "王" : "wang",
+ "网" : "wang",
+ "枉" : "wang",
+ "罔" : "wang",
+ "往" : "wang",
+ "惘" : "wang",
+ "妄" : "wang",
+ "忘" : "wang",
+ "旺" : "wang",
+ "望" : "wang",
+ "危" : "wei",
+ "威" : "wei",
+ "偎" : "wei",
+ "微" : "wei",
+ "煨" : "wei",
+ "薇" : "wei",
+ "巍" : "wei",
+ "韦" : "wei",
+ "为" : "wei",
+ "违" : "wei",
+ "围" : "wei",
+ "闱" : "wei",
+ "桅" : "wei",
+ "唯" : "wei",
+ "帷" : "wei",
+ "维" : "wei",
+ "伟" : "wei",
+ "伪" : "wei",
+ "苇" : "wei",
+ "纬" : "wei",
+ "委" : "wei",
+ "诿" : "wei",
+ "娓" : "wei",
+ "萎" : "wei",
+ "猥" : "wei",
+ "痿" : "wei",
+ "卫" : "wei",
+ "未" : "wei",
+ "位" : "wei",
+ "味" : "wei",
+ "畏" : "wei",
+ "胃" : "wei",
+ "谓" : "wei",
+ "喂" : "wei",
+ "猬" : "wei",
+ "渭" : "wei",
+ "蔚" : "wei",
+ "慰" : "wei",
+ "魏" : "wei",
+ "温" : "wen",
+ "瘟" : "wen",
+ "文" : "wen",
+ "纹" : "wen",
+ "闻" : "wen",
+ "蚊" : "wen",
+ "雯" : "wen",
+ "刎" : "wen",
+ "吻" : "wen",
+ "紊" : "wen",
+ "稳" : "wen",
+ "问" : "wen",
+ "汶" : "wen",
+ "翁" : "weng",
+ "嗡" : "weng",
+ "瓮" : "weng",
+ "挝" : "wo",
+ "莴" : "wo",
+ "倭" : "wo",
+ "喔" : "wo",
+ "窝" : "wo",
+ "蜗" : "wo",
+ "我" : "wo",
+ "肟" : "wo",
+ "沃" : "wo",
+ "卧" : "wo",
+ "握" : "wo",
+ "幄" : "wo",
+ "斡" : "wo",
+ "乌" : "wu",
+ "邬" : "wu",
+ "污" : "wu",
+ "巫" : "wu",
+ "呜" : "wu",
+ "钨" : "wu",
+ "诬" : "wu",
+ "屋" : "wu",
+ "无" : "wu",
+ "毋" : "wu",
+ "芜" : "wu",
+ "吴" : "wu",
+ "梧" : "wu",
+ "蜈" : "wu",
+ "五" : "wu",
+ "午" : "wu",
+ "伍" : "wu",
+ "仵" : "wu",
+ "怃" : "wu",
+ "忤" : "wu",
+ "妩" : "wu",
+ "武" : "wu",
+ "侮" : "wu",
+ "捂" : "wu",
+ "鹉" : "wu",
+ "舞" : "wu",
+ "兀" : "wu",
+ "勿" : "wu",
+ "戊" : "wu",
+ "务" : "wu",
+ "坞" : "wu",
+ "物" : "wu",
+ "误" : "wu",
+ "悟" : "wu",
+ "晤" : "wu",
+ "骛" : "wu",
+ "雾" : "wu",
+ "寤" : "wu",
+ "鹜" : "wu",
+ "夕" : "xi",
+ "兮" : "xi",
+ "西" : "xi",
+ "吸" : "xi",
+ "汐" : "xi",
+ "希" : "xi",
+ "昔" : "xi",
+ "析" : "xi",
+ "唏" : "xi",
+ "牺" : "xi",
+ "息" : "xi",
+ "奚" : "xi",
+ "悉" : "xi",
+ "烯" : "xi",
+ "惜" : "xi",
+ "晰" : "xi",
+ "稀" : "xi",
+ "翕" : "xi",
+ "犀" : "xi",
+ "皙" : "xi",
+ "锡" : "xi",
+ "溪" : "xi",
+ "熙" : "xi",
+ "蜥" : "xi",
+ "熄" : "xi",
+ "嘻" : "xi",
+ "膝" : "xi",
+ "嬉" : "xi",
+ "羲" : "xi",
+ "蟋" : "xi",
+ "曦" : "xi",
+ "习" : "xi",
+ "席" : "xi",
+ "袭" : "xi",
+ "媳" : "xi",
+ "洗" : "xi",
+ "玺" : "xi",
+ "徙" : "xi",
+ "喜" : "xi",
+ "禧" : "xi",
+ "戏" : "xi",
+ "细" : "xi",
+ "隙" : "xi",
+ "呷" : "xia",
+ "虾" : "xia",
+ "瞎" : "xia",
+ "匣" : "xia",
+ "侠" : "xia",
+ "峡" : "xia",
+ "狭" : "xia",
+ "遐" : "xia",
+ "瑕" : "xia",
+ "暇" : "xia",
+ "辖" : "xia",
+ "霞" : "xia",
+ "黠" : "xia",
+ "下" : "xia",
+ "夏" : "xia",
+ "罅" : "xia",
+ "仙" : "xian",
+ "先" : "xian",
+ "氙" : "xian",
+ "掀" : "xian",
+ "酰" : "xian",
+ "锨" : "xian",
+ "鲜" : "xian",
+ "闲" : "xian",
+ "贤" : "xian",
+ "弦" : "xian",
+ "咸" : "xian",
+ "涎" : "xian",
+ "娴" : "xian",
+ "衔" : "xian",
+ "舷" : "xian",
+ "嫌" : "xian",
+ "显" : "xian",
+ "险" : "xian",
+ "跣" : "xian",
+ "藓" : "xian",
+ "苋" : "xian",
+ "县" : "xian",
+ "现" : "xian",
+ "限" : "xian",
+ "线" : "xian",
+ "宪" : "xian",
+ "陷" : "xian",
+ "馅" : "xian",
+ "羡" : "xian",
+ "献" : "xian",
+ "腺" : "xian",
+ "乡" : "xiang",
+ "相" : "xiang",
+ "香" : "xiang",
+ "厢" : "xiang",
+ "湘" : "xiang",
+ "箱" : "xiang",
+ "襄" : "xiang",
+ "镶" : "xiang",
+ "详" : "xiang",
+ "祥" : "xiang",
+ "翔" : "xiang",
+ "享" : "xiang",
+ "响" : "xiang",
+ "饷" : "xiang",
+ "飨" : "xiang",
+ "想" : "xiang",
+ "向" : "xiang",
+ "项" : "xiang",
+ "象" : "xiang",
+ "像" : "xiang",
+ "橡" : "xiang",
+ "肖" : "xiao",
+ "枭" : "xiao",
+ "哓" : "xiao",
+ "骁" : "xiao",
+ "逍" : "xiao",
+ "消" : "xiao",
+ "宵" : "xiao",
+ "萧" : "xiao",
+ "硝" : "xiao",
+ "销" : "xiao",
+ "箫" : "xiao",
+ "潇" : "xiao",
+ "霄" : "xiao",
+ "魈" : "xiao",
+ "嚣" : "xiao",
+ "崤" : "xiao",
+ "淆" : "xiao",
+ "小" : "xiao",
+ "晓" : "xiao",
+ "孝" : "xiao",
+ "哮" : "xiao",
+ "笑" : "xiao",
+ "效" : "xiao",
+ "啸" : "xiao",
+ "挟" : "xie",
+ "些" : "xie",
+ "楔" : "xie",
+ "歇" : "xie",
+ "蝎" : "xie",
+ "协" : "xie",
+ "胁" : "xie",
+ "偕" : "xie",
+ "斜" : "xie",
+ "谐" : "xie",
+ "揳" : "xie",
+ "携" : "xie",
+ "撷" : "xie",
+ "鞋" : "xie",
+ "写" : "xie",
+ "泄" : "xie",
+ "泻" : "xie",
+ "卸" : "xie",
+ "屑" : "xie",
+ "械" : "xie",
+ "亵" : "xie",
+ "谢" : "xie",
+ "邂" : "xie",
+ "懈" : "xie",
+ "蟹" : "xie",
+ "心" : "xin",
+ "芯" : "xin",
+ "辛" : "xin",
+ "欣" : "xin",
+ "锌" : "xin",
+ "新" : "xin",
+ "歆" : "xin",
+ "薪" : "xin",
+ "馨" : "xin",
+ "鑫" : "xin",
+ "信" : "xin",
+ "衅" : "xin",
+ "星" : "xing",
+ "猩" : "xing",
+ "惺" : "xing",
+ "腥" : "xing",
+ "刑" : "xing",
+ "邢" : "xing",
+ "形" : "xing",
+ "型" : "xing",
+ "醒" : "xing",
+ "擤" : "xing",
+ "兴" : "xing",
+ "杏" : "xing",
+ "幸" : "xing",
+ "性" : "xing",
+ "姓" : "xing",
+ "悻" : "xing",
+ "凶" : "xiong",
+ "兄" : "xiong",
+ "匈" : "xiong",
+ "讻" : "xiong",
+ "汹" : "xiong",
+ "胸" : "xiong",
+ "雄" : "xiong",
+ "熊" : "xiong",
+ "休" : "xiu",
+ "咻" : "xiu",
+ "修" : "xiu",
+ "羞" : "xiu",
+ "朽" : "xiu",
+ "秀" : "xiu",
+ "袖" : "xiu",
+ "绣" : "xiu",
+ "锈" : "xiu",
+ "嗅" : "xiu",
+ "欻" : "xu",
+ "戌" : "xu",
+ "须" : "xu",
+ "胥" : "xu",
+ "虚" : "xu",
+ "墟" : "xu",
+ "需" : "xu",
+ "魆" : "xu",
+ "徐" : "xu",
+ "许" : "xu",
+ "诩" : "xu",
+ "栩" : "xu",
+ "旭" : "xu",
+ "序" : "xu",
+ "叙" : "xu",
+ "恤" : "xu",
+ "酗" : "xu",
+ "勖" : "xu",
+ "绪" : "xu",
+ "续" : "xu",
+ "絮" : "xu",
+ "婿" : "xu",
+ "蓄" : "xu",
+ "煦" : "xu",
+ "轩" : "xuan",
+ "宣" : "xuan",
+ "揎" : "xuan",
+ "喧" : "xuan",
+ "暄" : "xuan",
+ "玄" : "xuan",
+ "悬" : "xuan",
+ "旋" : "xuan",
+ "漩" : "xuan",
+ "璇" : "xuan",
+ "选" : "xuan",
+ "癣" : "xuan",
+ "炫" : "xuan",
+ "绚" : "xuan",
+ "眩" : "xuan",
+ "渲" : "xuan",
+ "靴" : "xue",
+ "薛" : "xue",
+ "穴" : "xue",
+ "学" : "xue",
+ "噱" : "xue",
+ "雪" : "xue",
+ "谑" : "xue",
+ "勋" : "xun",
+ "熏" : "xun",
+ "薰" : "xun",
+ "醺" : "xun",
+ "旬" : "xun",
+ "寻" : "xun",
+ "巡" : "xun",
+ "询" : "xun",
+ "荀" : "xun",
+ "循" : "xun",
+ "训" : "xun",
+ "讯" : "xun",
+ "汛" : "xun",
+ "迅" : "xun",
+ "驯" : "xun",
+ "徇" : "xun",
+ "逊" : "xun",
+ "殉" : "xun",
+ "巽" : "xun",
+ "丫" : "ya",
+ "压" : "ya",
+ "押" : "ya",
+ "鸦" : "ya",
+ "桠" : "ya",
+ "鸭" : "ya",
+ "牙" : "ya",
+ "伢" : "ya",
+ "芽" : "ya",
+ "蚜" : "ya",
+ "崖" : "ya",
+ "涯" : "ya",
+ "睚" : "ya",
+ "衙" : "ya",
+ "哑" : "ya",
+ "雅" : "ya",
+ "亚" : "ya",
+ "讶" : "ya",
+ "娅" : "ya",
+ "氩" : "ya",
+ "揠" : "ya",
+ "呀" : "ya",
+ "恹" : "yan",
+ "胭" : "yan",
+ "烟" : "yan",
+ "焉" : "yan",
+ "阉" : "yan",
+ "淹" : "yan",
+ "湮" : "yan",
+ "嫣" : "yan",
+ "延" : "yan",
+ "闫" : "yan",
+ "严" : "yan",
+ "言" : "yan",
+ "妍" : "yan",
+ "岩" : "yan",
+ "炎" : "yan",
+ "沿" : "yan",
+ "研" : "yan",
+ "盐" : "yan",
+ "阎" : "yan",
+ "蜒" : "yan",
+ "筵" : "yan",
+ "颜" : "yan",
+ "檐" : "yan",
+ "奄" : "yan",
+ "俨" : "yan",
+ "衍" : "yan",
+ "掩" : "yan",
+ "郾" : "yan",
+ "眼" : "yan",
+ "偃" : "yan",
+ "演" : "yan",
+ "魇" : "yan",
+ "鼹" : "yan",
+ "厌" : "yan",
+ "砚" : "yan",
+ "彦" : "yan",
+ "艳" : "yan",
+ "晏" : "yan",
+ "唁" : "yan",
+ "宴" : "yan",
+ "验" : "yan",
+ "谚" : "yan",
+ "堰" : "yan",
+ "雁" : "yan",
+ "焰" : "yan",
+ "滟" : "yan",
+ "餍" : "yan",
+ "燕" : "yan",
+ "赝" : "yan",
+ "央" : "yang",
+ "泱" : "yang",
+ "殃" : "yang",
+ "鸯" : "yang",
+ "秧" : "yang",
+ "扬" : "yang",
+ "羊" : "yang",
+ "阳" : "yang",
+ "杨" : "yang",
+ "佯" : "yang",
+ "疡" : "yang",
+ "徉" : "yang",
+ "洋" : "yang",
+ "仰" : "yang",
+ "养" : "yang",
+ "氧" : "yang",
+ "痒" : "yang",
+ "怏" : "yang",
+ "样" : "yang",
+ "恙" : "yang",
+ "烊" : "yang",
+ "漾" : "yang",
+ "幺" : "yao",
+ "夭" : "yao",
+ "吆" : "yao",
+ "妖" : "yao",
+ "腰" : "yao",
+ "邀" : "yao",
+ "爻" : "yao",
+ "尧" : "yao",
+ "肴" : "yao",
+ "姚" : "yao",
+ "窑" : "yao",
+ "谣" : "yao",
+ "摇" : "yao",
+ "徭" : "yao",
+ "遥" : "yao",
+ "瑶" : "yao",
+ "杳" : "yao",
+ "咬" : "yao",
+ "舀" : "yao",
+ "窈" : "yao",
+ "药" : "yao",
+ "要" : "yao",
+ "鹞" : "yao",
+ "耀" : "yao",
+ "耶" : "ye",
+ "掖" : "ye",
+ "椰" : "ye",
+ "噎" : "ye",
+ "爷" : "ye",
+ "揶" : "ye",
+ "也" : "ye",
+ "冶" : "ye",
+ "野" : "ye",
+ "业" : "ye",
+ "叶" : "ye",
+ "页" : "ye",
+ "曳" : "ye",
+ "夜" : "ye",
+ "液" : "ye",
+ "谒" : "ye",
+ "腋" : "ye",
+ "一" : "yi",
+ "伊" : "yi",
+ "衣" : "yi",
+ "医" : "yi",
+ "依" : "yi",
+ "咿" : "yi",
+ "揖" : "yi",
+ "壹" : "yi",
+ "漪" : "yi",
+ "噫" : "yi",
+ "仪" : "yi",
+ "夷" : "yi",
+ "饴" : "yi",
+ "宜" : "yi",
+ "咦" : "yi",
+ "贻" : "yi",
+ "姨" : "yi",
+ "胰" : "yi",
+ "移" : "yi",
+ "痍" : "yi",
+ "颐" : "yi",
+ "疑" : "yi",
+ "彝" : "yi",
+ "乙" : "yi",
+ "已" : "yi",
+ "以" : "yi",
+ "苡" : "yi",
+ "矣" : "yi",
+ "迤" : "yi",
+ "蚁" : "yi",
+ "倚" : "yi",
+ "椅" : "yi",
+ "旖" : "yi",
+ "乂" : "yi",
+ "亿" : "yi",
+ "义" : "yi",
+ "艺" : "yi",
+ "刈" : "yi",
+ "忆" : "yi",
+ "议" : "yi",
+ "屹" : "yi",
+ "亦" : "yi",
+ "异" : "yi",
+ "抑" : "yi",
+ "呓" : "yi",
+ "邑" : "yi",
+ "役" : "yi",
+ "译" : "yi",
+ "易" : "yi",
+ "诣" : "yi",
+ "绎" : "yi",
+ "驿" : "yi",
+ "轶" : "yi",
+ "弈" : "yi",
+ "奕" : "yi",
+ "疫" : "yi",
+ "羿" : "yi",
+ "益" : "yi",
+ "谊" : "yi",
+ "逸" : "yi",
+ "翌" : "yi",
+ "肄" : "yi",
+ "裔" : "yi",
+ "意" : "yi",
+ "溢" : "yi",
+ "缢" : "yi",
+ "毅" : "yi",
+ "薏" : "yi",
+ "翳" : "yi",
+ "臆" : "yi",
+ "翼" : "yi",
+ "因" : "yin",
+ "阴" : "yin",
+ "茵" : "yin",
+ "荫" : "yin",
+ "音" : "yin",
+ "姻" : "yin",
+ "铟" : "yin",
+ "喑" : "yin",
+ "愔" : "yin",
+ "吟" : "yin",
+ "垠" : "yin",
+ "银" : "yin",
+ "淫" : "yin",
+ "寅" : "yin",
+ "龈" : "yin",
+ "霪" : "yin",
+ "尹" : "yin",
+ "引" : "yin",
+ "蚓" : "yin",
+ "隐" : "yin",
+ "瘾" : "yin",
+ "印" : "yin",
+ "英" : "ying",
+ "莺" : "ying",
+ "婴" : "ying",
+ "嘤" : "ying",
+ "罂" : "ying",
+ "缨" : "ying",
+ "樱" : "ying",
+ "鹦" : "ying",
+ "膺" : "ying",
+ "鹰" : "ying",
+ "迎" : "ying",
+ "茔" : "ying",
+ "荧" : "ying",
+ "盈" : "ying",
+ "莹" : "ying",
+ "萤" : "ying",
+ "营" : "ying",
+ "萦" : "ying",
+ "楹" : "ying",
+ "蝇" : "ying",
+ "赢" : "ying",
+ "瀛" : "ying",
+ "颍" : "ying",
+ "颖" : "ying",
+ "影" : "ying",
+ "应" : "ying",
+ "映" : "ying",
+ "硬" : "ying",
+ "哟" : "yo",
+ "唷" : "yo",
+ "佣" : "yong",
+ "拥" : "yong",
+ "庸" : "yong",
+ "雍" : "yong",
+ "壅" : "yong",
+ "臃" : "yong",
+ "永" : "yong",
+ "甬" : "yong",
+ "咏" : "yong",
+ "泳" : "yong",
+ "勇" : "yong",
+ "涌" : "yong",
+ "恿" : "yong",
+ "蛹" : "yong",
+ "踊" : "yong",
+ "用" : "yong",
+ "优" : "you",
+ "攸" : "you",
+ "忧" : "you",
+ "呦" : "you",
+ "幽" : "you",
+ "悠" : "you",
+ "尤" : "you",
+ "由" : "you",
+ "邮" : "you",
+ "犹" : "you",
+ "油" : "you",
+ "铀" : "you",
+ "鱿" : "you",
+ "游" : "you",
+ "友" : "you",
+ "有" : "you",
+ "酉" : "you",
+ "莠" : "you",
+ "黝" : "you",
+ "又" : "you",
+ "右" : "you",
+ "幼" : "you",
+ "佑" : "you",
+ "柚" : "you",
+ "囿" : "you",
+ "诱" : "you",
+ "鼬" : "you",
+ "迂" : "yu",
+ "纡" : "yu",
+ "於" : "yu",
+ "淤" : "yu",
+ "瘀" : "yu",
+ "于" : "yu",
+ "余" : "yu",
+ "盂" : "yu",
+ "臾" : "yu",
+ "鱼" : "yu",
+ "竽" : "yu",
+ "俞" : "yu",
+ "狳" : "yu",
+ "谀" : "yu",
+ "娱" : "yu",
+ "渔" : "yu",
+ "隅" : "yu",
+ "揄" : "yu",
+ "逾" : "yu",
+ "腴" : "yu",
+ "渝" : "yu",
+ "愉" : "yu",
+ "瑜" : "yu",
+ "榆" : "yu",
+ "虞" : "yu",
+ "愚" : "yu",
+ "舆" : "yu",
+ "与" : "yu",
+ "予" : "yu",
+ "屿" : "yu",
+ "宇" : "yu",
+ "羽" : "yu",
+ "雨" : "yu",
+ "禹" : "yu",
+ "语" : "yu",
+ "圄" : "yu",
+ "玉" : "yu",
+ "驭" : "yu",
+ "芋" : "yu",
+ "妪" : "yu",
+ "郁" : "yu",
+ "育" : "yu",
+ "狱" : "yu",
+ "浴" : "yu",
+ "预" : "yu",
+ "域" : "yu",
+ "欲" : "yu",
+ "谕" : "yu",
+ "遇" : "yu",
+ "喻" : "yu",
+ "御" : "yu",
+ "寓" : "yu",
+ "裕" : "yu",
+ "愈" : "yu",
+ "誉" : "yu",
+ "豫" : "yu",
+ "鹬" : "yu",
+ "鸢" : "yuan",
+ "鸳" : "yuan",
+ "冤" : "yuan",
+ "渊" : "yuan",
+ "元" : "yuan",
+ "园" : "yuan",
+ "垣" : "yuan",
+ "袁" : "yuan",
+ "原" : "yuan",
+ "圆" : "yuan",
+ "援" : "yuan",
+ "媛" : "yuan",
+ "缘" : "yuan",
+ "猿" : "yuan",
+ "源" : "yuan",
+ "辕" : "yuan",
+ "远" : "yuan",
+ "苑" : "yuan",
+ "怨" : "yuan",
+ "院" : "yuan",
+ "愿" : "yuan",
+ "曰" : "yue",
+ "月" : "yue",
+ "岳" : "yue",
+ "钺" : "yue",
+ "阅" : "yue",
+ "悦" : "yue",
+ "跃" : "yue",
+ "越" : "yue",
+ "粤" : "yue",
+ "晕" : "yun",
+ "云" : "yun",
+ "匀" : "yun",
+ "芸" : "yun",
+ "纭" : "yun",
+ "耘" : "yun",
+ "允" : "yun",
+ "陨" : "yun",
+ "殒" : "yun",
+ "孕" : "yun",
+ "运" : "yun",
+ "酝" : "yun",
+ "愠" : "yun",
+ "韵" : "yun",
+ "蕴" : "yun",
+ "熨" : "yun",
+ "匝" : "za",
+ "咂" : "za",
+ "杂" : "za",
+ "砸" : "za",
+ "灾" : "zai",
+ "甾" : "zai",
+ "哉" : "zai",
+ "栽" : "zai",
+ "载" : "zai",
+ "宰" : "zai",
+ "崽" : "zai",
+ "再" : "zai",
+ "在" : "zai",
+ "糌" : "zan",
+ "簪" : "zan",
+ "咱" : "zan",
+ "趱" : "zan",
+ "暂" : "zan",
+ "錾" : "zan",
+ "赞" : "zan",
+ "赃" : "zang",
+ "脏" : "zang",
+ "臧" : "zang",
+ "驵" : "zang",
+ "葬" : "zang",
+ "遭" : "zao",
+ "糟" : "zao",
+ "凿" : "zao",
+ "早" : "zao",
+ "枣" : "zao",
+ "蚤" : "zao",
+ "澡" : "zao",
+ "藻" : "zao",
+ "皂" : "zao",
+ "灶" : "zao",
+ "造" : "zao",
+ "噪" : "zao",
+ "燥" : "zao",
+ "躁" : "zao",
+ "则" : "ze",
+ "责" : "ze",
+ "泽" : "ze",
+ "啧" : "ze",
+ "帻" : "ze",
+ "仄" : "ze",
+ "贼" : "zei",
+ "怎" : "zen",
+ "谮" : "zen",
+ "增" : "zeng",
+ "憎" : "zeng",
+ "锃" : "zeng",
+ "赠" : "zeng",
+ "甑" : "zeng",
+ "吒" : "zha",
+ "挓" : "zha",
+ "哳" : "zha",
+ "揸" : "zha",
+ "渣" : "zha",
+ "楂" : "zha",
+ "札" : "zha",
+ "闸" : "zha",
+ "铡" : "zha",
+ "眨" : "zha",
+ "砟" : "zha",
+ "乍" : "zha",
+ "诈" : "zha",
+ "咤" : "zha",
+ "炸" : "zha",
+ "蚱" : "zha",
+ "榨" : "zha",
+ "拃" : "zha",
+ "斋" : "zhai",
+ "摘" : "zhai",
+ "宅" : "zhai",
+ "窄" : "zhai",
+ "债" : "zhai",
+ "砦" : "zhai",
+ "寨" : "zhai",
+ "沾" : "zhan",
+ "毡" : "zhan",
+ "粘" : "zhan",
+ "詹" : "zhan",
+ "谵" : "zhan",
+ "瞻" : "zhan",
+ "斩" : "zhan",
+ "盏" : "zhan",
+ "展" : "zhan",
+ "崭" : "zhan",
+ "搌" : "zhan",
+ "辗" : "zhan",
+ "占" : "zhan",
+ "栈" : "zhan",
+ "战" : "zhan",
+ "站" : "zhan",
+ "绽" : "zhan",
+ "湛" : "zhan",
+ "蘸" : "zhan",
+ "张" : "zhang",
+ "章" : "zhang",
+ "獐" : "zhang",
+ "彰" : "zhang",
+ "樟" : "zhang",
+ "蟑" : "zhang",
+ "涨" : "zhang",
+ "掌" : "zhang",
+ "丈" : "zhang",
+ "仗" : "zhang",
+ "杖" : "zhang",
+ "帐" : "zhang",
+ "账" : "zhang",
+ "胀" : "zhang",
+ "障" : "zhang",
+ "嶂" : "zhang",
+ "瘴" : "zhang",
+ "钊" : "zhao",
+ "招" : "zhao",
+ "昭" : "zhao",
+ "找" : "zhao",
+ "沼" : "zhao",
+ "兆" : "zhao",
+ "诏" : "zhao",
+ "赵" : "zhao",
+ "照" : "zhao",
+ "罩" : "zhao",
+ "肇" : "zhao",
+ "蜇" : "zhe",
+ "遮" : "zhe",
+ "哲" : "zhe",
+ "辄" : "zhe",
+ "蛰" : "zhe",
+ "谪" : "zhe",
+ "辙" : "zhe",
+ "者" : "zhe",
+ "锗" : "zhe",
+ "赭" : "zhe",
+ "褶" : "zhe",
+ "浙" : "zhe",
+ "蔗" : "zhe",
+ "鹧" : "zhe",
+ "贞" : "zhen",
+ "针" : "zhen",
+ "侦" : "zhen",
+ "珍" : "zhen",
+ "帧" : "zhen",
+ "胗" : "zhen",
+ "真" : "zhen",
+ "砧" : "zhen",
+ "斟" : "zhen",
+ "甄" : "zhen",
+ "榛" : "zhen",
+ "箴" : "zhen",
+ "臻" : "zhen",
+ "诊" : "zhen",
+ "枕" : "zhen",
+ "疹" : "zhen",
+ "缜" : "zhen",
+ "阵" : "zhen",
+ "鸩" : "zhen",
+ "振" : "zhen",
+ "朕" : "zhen",
+ "赈" : "zhen",
+ "震" : "zhen",
+ "镇" : "zhen",
+ "争" : "zheng",
+ "征" : "zheng",
+ "怔" : "zheng",
+ "峥" : "zheng",
+ "狰" : "zheng",
+ "睁" : "zheng",
+ "铮" : "zheng",
+ "筝" : "zheng",
+ "蒸" : "zheng",
+ "拯" : "zheng",
+ "整" : "zheng",
+ "正" : "zheng",
+ "证" : "zheng",
+ "郑" : "zheng",
+ "诤" : "zheng",
+ "政" : "zheng",
+ "挣" : "zheng",
+ "症" : "zheng",
+ "之" : "zhi",
+ "支" : "zhi",
+ "只" : "zhi",
+ "汁" : "zhi",
+ "芝" : "zhi",
+ "吱" : "zhi",
+ "枝" : "zhi",
+ "知" : "zhi",
+ "肢" : "zhi",
+ "织" : "zhi",
+ "栀" : "zhi",
+ "脂" : "zhi",
+ "蜘" : "zhi",
+ "执" : "zhi",
+ "直" : "zhi",
+ "侄" : "zhi",
+ "值" : "zhi",
+ "职" : "zhi",
+ "植" : "zhi",
+ "跖" : "zhi",
+ "踯" : "zhi",
+ "止" : "zhi",
+ "旨" : "zhi",
+ "址" : "zhi",
+ "芷" : "zhi",
+ "纸" : "zhi",
+ "祉" : "zhi",
+ "指" : "zhi",
+ "枳" : "zhi",
+ "咫" : "zhi",
+ "趾" : "zhi",
+ "酯" : "zhi",
+ "至" : "zhi",
+ "志" : "zhi",
+ "豸" : "zhi",
+ "帜" : "zhi",
+ "制" : "zhi",
+ "质" : "zhi",
+ "炙" : "zhi",
+ "治" : "zhi",
+ "栉" : "zhi",
+ "峙" : "zhi",
+ "挚" : "zhi",
+ "桎" : "zhi",
+ "致" : "zhi",
+ "秩" : "zhi",
+ "掷" : "zhi",
+ "痔" : "zhi",
+ "窒" : "zhi",
+ "蛭" : "zhi",
+ "智" : "zhi",
+ "痣" : "zhi",
+ "滞" : "zhi",
+ "置" : "zhi",
+ "雉" : "zhi",
+ "稚" : "zhi",
+ "中" : "zhong",
+ "忠" : "zhong",
+ "终" : "zhong",
+ "盅" : "zhong",
+ "钟" : "zhong",
+ "衷" : "zhong",
+ "肿" : "zhong",
+ "冢" : "zhong",
+ "踵" : "zhong",
+ "仲" : "zhong",
+ "众" : "zhong",
+ "舟" : "zhou",
+ "州" : "zhou",
+ "诌" : "zhou",
+ "周" : "zhou",
+ "洲" : "zhou",
+ "粥" : "zhou",
+ "妯" : "zhou",
+ "轴" : "zhou",
+ "肘" : "zhou",
+ "纣" : "zhou",
+ "咒" : "zhou",
+ "宙" : "zhou",
+ "胄" : "zhou",
+ "昼" : "zhou",
+ "皱" : "zhou",
+ "骤" : "zhou",
+ "帚" : "zhou",
+ "朱" : "zhu",
+ "侏" : "zhu",
+ "诛" : "zhu",
+ "茱" : "zhu",
+ "珠" : "zhu",
+ "株" : "zhu",
+ "诸" : "zhu",
+ "铢" : "zhu",
+ "猪" : "zhu",
+ "蛛" : "zhu",
+ "竹" : "zhu",
+ "竺" : "zhu",
+ "逐" : "zhu",
+ "烛" : "zhu",
+ "躅" : "zhu",
+ "主" : "zhu",
+ "拄" : "zhu",
+ "煮" : "zhu",
+ "嘱" : "zhu",
+ "瞩" : "zhu",
+ "伫" : "zhu",
+ "苎" : "zhu",
+ "助" : "zhu",
+ "住" : "zhu",
+ "贮" : "zhu",
+ "注" : "zhu",
+ "驻" : "zhu",
+ "柱" : "zhu",
+ "祝" : "zhu",
+ "著" : "zhu",
+ "蛀" : "zhu",
+ "铸" : "zhu",
+ "筑" : "zhu",
+ "抓" : "zhua",
+ "跩" : "zhuai",
+ "拽" : "zhuai",
+ "专" : "zhuan",
+ "砖" : "zhuan",
+ "转" : "zhuan",
+ "啭" : "zhuan",
+ "撰" : "zhuan",
+ "篆" : "zhuan",
+ "妆" : "zhuang",
+ "庄" : "zhuang",
+ "桩" : "zhuang",
+ "装" : "zhuang",
+ "壮" : "zhuang",
+ "状" : "zhuang",
+ "撞" : "zhuang",
+ "幢" : "zhuang",
+ "追" : "zhui",
+ "骓" : "zhui",
+ "锥" : "zhui",
+ "坠" : "zhui",
+ "缀" : "zhui",
+ "惴" : "zhui",
+ "赘" : "zhui",
+ "谆" : "zhun",
+ "准" : "zhun",
+ "拙" : "zhuo",
+ "捉" : "zhuo",
+ "桌" : "zhuo",
+ "灼" : "zhuo",
+ "茁" : "zhuo",
+ "卓" : "zhuo",
+ "斫" : "zhuo",
+ "浊" : "zhuo",
+ "酌" : "zhuo",
+ "啄" : "zhuo",
+ "擢" : "zhuo",
+ "镯" : "zhuo",
+ "孜" : "zi",
+ "咨" : "zi",
+ "姿" : "zi",
+ "赀" : "zi",
+ "资" : "zi",
+ "辎" : "zi",
+ "嗞" : "zi",
+ "滋" : "zi",
+ "锱" : "zi",
+ "龇" : "zi",
+ "子" : "zi",
+ "姊" : "zi",
+ "秭" : "zi",
+ "籽" : "zi",
+ "梓" : "zi",
+ "紫" : "zi",
+ "訾" : "zi",
+ "滓" : "zi",
+ "自" : "zi",
+ "字" : "zi",
+ "恣" : "zi",
+ "眦" : "zi",
+ "渍" : "zi",
+ "宗" : "zong",
+ "综" : "zong",
+ "棕" : "zong",
+ "踪" : "zong",
+ "鬃" : "zong",
+ "总" : "zong",
+ "纵" : "zong",
+ "粽" : "zong",
+ "邹" : "zou",
+ "走" : "zou",
+ "奏" : "zou",
+ "揍" : "zou",
+ "租" : "zu",
+ "足" : "zu",
+ "卒" : "zu",
+ "族" : "zu",
+ "诅" : "zu",
+ "阻" : "zu",
+ "组" : "zu",
+ "俎" : "zu",
+ "祖" : "zu",
+ "纂" : "zuan",
+ "钻" : "zuan",
+ "攥" : "zuan",
+ "嘴" : "zui",
+ "最" : "zui",
+ "罪" : "zui",
+ "醉" : "zui",
+ "尊" : "zun",
+ "遵" : "zun",
+ "樽" : "zun",
+ "鳟" : "zun",
+ "昨" : "zuo",
+ "左" : "zuo",
+ "佐" : "zuo",
+ "作" : "zuo",
+ "坐" : "zuo",
+ "阼" : "zuo",
+ "怍" : "zuo",
+ "祚" : "zuo",
+ "唑" : "zuo",
+ "座" : "zuo",
+ "做" : "zuo",
+ "酢" : "zuo",
+ "斌" : "bin",
+ "曾" : "zeng",
+ "查" : "zha",
+ "査" : "zha",
+ "乘" : "cheng",
+ "传" : "chuan",
+ "丁" : "ding",
+ "行" : "xing",
+ "瑾" : "jin",
+ "婧" : "jing",
+ "恺" : "kai",
+ "阚" : "kan",
+ "奎" : "kui",
+ "乐" : "le",
+ "陆" : "lu",
+ "逯" : "lv",
+ "璐" : "lu",
+ "淼" : "miao",
+ "闵" : "min",
+ "娜" : "na",
+ "奇" : "qi",
+ "琦" : "qi",
+ "强" : "qiang",
+ "邱" : "qiu",
+ "芮" : "rui",
+ "莎" : "sha",
+ "盛" : "sheng",
+ "石" : "shi",
+ "祎" : "yi",
+ "殷" : "yin",
+ "瑛" : "ying",
+ "昱" : "yu",
+ "眃" : "yun",
+ "琢" : "zhuo",
+ "枰" : "ping",
+ "玟" : "min",
+ "珉" : "min",
+ "珣" : "xun",
+ "淇" : "qi",
+ "缈" : "miao",
+ "彧" : "yu",
+ "祺" : "qi",
+ "骞" : "qian",
+ "垚" : "yao",
+ "妸" : "e",
+ "烜" : "hui",
+ "祁" : "qi",
+ "傢" : "jia",
+ "珮" : "pei",
+ "濮" : "pu",
+ "屺" : "qi",
+ "珅" : "shen",
+ "缇" : "ti",
+ "霈" : "pei",
+ "晞" : "xi",
+ "璠" : "fan",
+ "骐" : "qi",
+ "姞" : "ji",
+ "偲" : "cai",
+ "齼" : "chu",
+ "宓" : "mi",
+ "朴" : "pu",
+ "萁" : "qi",
+ "颀" : "qi",
+ "阗" : "tian",
+ "湉" : "tian",
+ "翀" : "chong",
+ "岷" : "min",
+ "桤" : "qi",
+ "囯" : "guo",
+ "浛" : "han",
+ "勐" : "meng",
+ "苠" : "min",
+ "岍" : "qian",
+ "皞" : "hao",
+ "岐" : "qi",
+ "溥" : "pu",
+ "锘" : "muo",
+ "渼" : "mei",
+ "燊" : "shen",
+ "玚" : "chang",
+ "亓" : "qi",
+ "湋" : "wei",
+ "涴" : "wan",
+ "沤" : "ou",
+ "胖" : "pang",
+ "莆" : "pu",
+ "扦" : "qian",
+ "僳" : "su",
+ "坍" : "tan",
+ "锑" : "ti",
+ "嚏" : "ti",
+ "腆" : "tian",
+ "丿" : "pie",
+ "鼗" : "tao",
+ "芈" : "mi",
+ "匚" : "fang",
+ "刂" : "li",
+ "冂" : "tong",
+ "亻" : "dan",
+ "仳" : "pi",
+ "俜" : "ping",
+ "俳" : "pai",
+ "倜" : "ti",
+ "傥" : "tang",
+ "傩" : "nuo",
+ "佥" : "qian",
+ "勹" : "bao",
+ "亠" : "tou",
+ "廾" : "gong",
+ "匏" : "pao",
+ "扌" : "ti",
+ "拚" : "pin",
+ "掊" : "pou",
+ "搦" : "nuo",
+ "擗" : "pi",
+ "啕" : "tao",
+ "嗦" : "suo",
+ "嗍" : "suo",
+ "辔" : "pei",
+ "嘌" : "piao",
+ "嗾" : "sou",
+ "嘧" : "mi",
+ "帔" : "pei",
+ "帑" : "tang",
+ "彡" : "san",
+ "犭" : "fan",
+ "狍" : "pao",
+ "狲" : "sun",
+ "狻" : "jun",
+ "飧" : "sun",
+ "夂" : "zhi",
+ "饣" : "shi",
+ "庀" : "pi",
+ "忄" : "shu",
+ "愫" : "su",
+ "闼" : "ta",
+ "丬" : "jiang",
+ "氵" : "san",
+ "汔" : "qi",
+ "沔" : "mian",
+ "汨" : "mi",
+ "泮" : "pan",
+ "洮" : "tao",
+ "涑" : "su",
+ "淠" : "pi",
+ "湓" : "pen",
+ "溻" : "ta",
+ "溏" : "tang",
+ "濉" : "sui",
+ "宀" : "bao",
+ "搴" : "qian",
+ "辶" : "zou",
+ "逄" : "pang",
+ "逖" : "ti",
+ "遢" : "ta",
+ "邈" : "miao",
+ "邃" : "sui",
+ "彐" : "ji",
+ "屮" : "cao",
+ "娑" : "suo",
+ "嫖" : "piao",
+ "纟" : "jiao",
+ "缗" : "min",
+ "瑭" : "tang",
+ "杪" : "miao",
+ "桫" : "suo",
+ "榀" : "pin",
+ "榫" : "sun",
+ "槭" : "qi",
+ "甓" : "pi",
+ "攴" : "po",
+ "耆" : "qi",
+ "牝" : "pin",
+ "犏" : "pian",
+ "氆" : "pu",
+ "攵" : "fan",
+ "肽" : "tai",
+ "胼" : "pian",
+ "脒" : "mi",
+ "脬" : "pao",
+ "旆" : "pei",
+ "炱" : "tai",
+ "燧" : "sui",
+ "灬" : "biao",
+ "礻" : "shi",
+ "祧" : "tiao",
+ "忑" : "te",
+ "忐" : "tan",
+ "愍" : "min",
+ "肀" : "yu",
+ "碛" : "qi",
+ "眄" : "mian",
+ "眇" : "miao",
+ "眭" : "sui",
+ "睃" : "suo",
+ "瞍" : "sou",
+ "畋" : "tian",
+ "罴" : "pi",
+ "蠓" : "meng",
+ "蠛" : "mie",
+ "笸" : "po",
+ "筢" : "pa",
+ "衄" : "nv",
+ "艋" : "meng",
+ "敉" : "mi",
+ "糸" : "mi",
+ "綦" : "qi",
+ "醅" : "pei",
+ "醣" : "tang",
+ "趿" : "ta",
+ "觫" : "su",
+ "龆" : "tiao",
+ "鲆" : "ping",
+ "稣" : "su",
+ "鲐" : "tai",
+ "鲦" : "tiao",
+ "鳎" : "ta",
+ "髂" : "qia",
+ "縻" : "mi",
+ "裒" : "pou",
+ "冫" : "liang",
+ "冖" : "tu",
+ "讠" : "yan",
+ "谇" : "sui",
+ "谝" : "pian",
+ "谡" : "su",
+ "卩" : "dan",
+ "阝" : "zuo",
+ "陴" : "pi",
+ "邳" : "pi",
+ "郫" : "pi",
+ "郯" : "tan",
+ "廴" : "yin",
+ "凵" : "qian",
+ "圮" : "pi",
+ "堋" : "peng",
+ "鼙" : "pi",
+ "艹" : "cao",
+ "芑" : "qi",
+ "苤" : "pie",
+ "荪" : "sun",
+ "荽" : "sui",
+ "葜" : "qia",
+ "蒎" : "pai",
+ "蔌" : "su",
+ "蕲" : "qi",
+ "薮" : "sou",
+ "薹" : "tai",
+ "蘼" : "mi",
+ "钅" : "jin",
+ "钷" : "po",
+ "钽" : "tan",
+ "铍" : "pi",
+ "铴" : "tang",
+ "铽" : "te",
+ "锫" : "pei",
+ "锬" : "tan",
+ "锼" : "sou",
+ "镤" : "pu",
+ "镨" : "pu",
+ "皤" : "po",
+ "鹈" : "ti",
+ "鹋" : "miao",
+ "疒" : "bing",
+ "疱" : "pao",
+ "衤" : "yi",
+ "袢" : "pan",
+ "裼" : "ti",
+ "襻" : "pan",
+ "耥" : "tang",
+ "耦" : "ou",
+ "虍" : "hu",
+ "蛴" : "qi",
+ "蜞" : "qi",
+ "蜱" : "pi",
+ "螋" : "sou",
+ "螗" : "tang",
+ "螵" : "piao",
+ "蟛" : "peng"
+}
diff --git a/kirby/i18n/translations/bg.json b/kirby/i18n/translations/bg.json
new file mode 100755
index 0000000..e76e81c
--- /dev/null
+++ b/kirby/i18n/translations/bg.json
@@ -0,0 +1,481 @@
+{
+ "add": "\u0414\u043e\u0431\u0430\u0432\u0438",
+ "avatar": "Профилна снимка",
+ "back": "Назад",
+ "cancel": "\u041e\u0442\u043a\u0430\u0436\u0438",
+ "change": "\u041f\u0440\u043e\u043c\u0435\u043d\u0438",
+ "close": "\u0417\u0430\u0442\u0432\u043e\u0440\u0438",
+ "confirm": "Ок",
+ "copy": "Копирай",
+ "create": "Създай",
+
+ "date": "Дата",
+ "date.select": "Select a date",
+
+ "day": "Day",
+ "days.fri": "\u041f\u0442",
+ "days.mon": "\u041f\u043d",
+ "days.sat": "\u0421\u0431",
+ "days.sun": "\u041d\u0434",
+ "days.thu": "\u0427\u0442",
+ "days.tue": "\u0412\u0442",
+ "days.wed": "\u0421\u0440",
+
+ "delete": "\u0418\u0437\u0442\u0440\u0438\u0439",
+ "dimensions": "Размери",
+ "disabled": "Disabled",
+ "discard": "\u041e\u0442\u043c\u0435\u043d\u0438",
+ "download": "Download",
+ "duplicate": "Duplicate",
+ "edit": "\u0420\u0435\u0434\u0430\u043a\u0442\u0438\u0440\u0430\u0439",
+
+ "dialog.files.empty": "No files to select",
+ "dialog.pages.empty": "No pages to select",
+ "dialog.users.empty": "No users to select",
+
+ "email": "Email",
+ "email.placeholder": "mail@example.com",
+
+ "error.access.login": "Invalid login",
+ "error.access.panel": "Нямате права за достъп до панела",
+ "error.access.view": "You are not allowed to access this part of the panel",
+
+ "error.avatar.create.fail": "Профилната снимка не може да се качи",
+ "error.avatar.delete.fail": "Профилната снимка не може да бъде изтрита",
+ "error.avatar.dimensions.invalid":
+ "Моля запазете ширината и височината на профилната снимка под 3000 пиксела",
+ "error.avatar.mime.forbidden":
+ "Профилната снимка трябва да бъде в JPEG или PNG формат",
+
+ "error.blueprint.notFound": "Образецът \"{name}\" не може да бъде зареден",
+
+ "error.email.preset.notFound": "Email шаблонът \"{name}\" не може да бъде открит",
+
+ "error.field.converter.invalid": "Невалиден конвертор \"{converter}\"",
+
+ "error.file.changeName.empty": "The name must not be empty",
+ "error.file.changeName.permission":
+ "Не можете да смените името на \"{filename}\"",
+ "error.file.duplicate": "Файл с име \"{filename}\" вече съществува",
+ "error.file.extension.forbidden":
+ "Файловото разширение \"{extension}\" не е позволено",
+ "error.file.extension.missing":
+ "Липсва файлово разширение за файла \"{filename}\"",
+ "error.file.maxheight": "The height of the image must not exceed {height} pixels",
+ "error.file.maxsize": "The file is too large",
+ "error.file.maxwidth": "The width of the image must not exceed {width} pixels",
+ "error.file.mime.differs":
+ "Каченият файл трябва да бъде от същия mime тип \"{mime}\"",
+ "error.file.mime.forbidden": "The media type \"{mime}\" is not allowed",
+ "error.file.mime.invalid": "Invalid mime type: {mime}",
+ "error.file.mime.missing":
+ "The media type for \"{filename}\" cannot be detected",
+ "error.file.minheight": "The height of the image must be at least {height} pixels",
+ "error.file.minsize": "The file is too small",
+ "error.file.minwidth": "The width of the image must be at least {width} pixels",
+ "error.file.name.missing": "Името на файла е задължително",
+ "error.file.notFound": "Файлът \"{filename}\" не може да бъде намерен",
+ "error.file.orientation": "The orientation of the image must be \"{orientation}\"",
+ "error.file.type.forbidden": "Не е позволен ъплоуда на файлове от тип {type}",
+ "error.file.undefined": "\u0424\u0430\u0439\u043b\u044a\u0442 \u043d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0431\u044a\u0434\u0435 \u043d\u0430\u043c\u0435\u0440\u0435\u043d",
+
+ "error.form.incomplete": "Моля коригирайте всички грешки във формата...",
+ "error.form.notSaved": "Формата не може да бъде запазена",
+
+ "error.language.code": "Please enter a valid code for the language",
+ "error.language.duplicate": "The language already exists",
+ "error.language.name": "Please enter a valid name for the language",
+
+ "error.license.format": "Please enter a valid license key",
+ "error.license.email": "Моля въведете валиден email адрес",
+ "error.license.verification": "The license could not be verified",
+
+ "error.page.changeSlug.permission":
+ "Не можете да смените URL на \"{slug}\"",
+ "error.page.changeStatus.incomplete":
+ "Страницата съдържа грешки и не може да бъде публикувана",
+ "error.page.changeStatus.permission":
+ "Статусът на страницата не може да бъде променен",
+ "error.page.changeStatus.toDraft.invalid":
+ "Страницата \"{slug}\" не може да бъде променена в чернова",
+ "error.page.changeTemplate.invalid":
+ "Темплейтът за страница \"{slug}\" не може да бъде променен",
+ "error.page.changeTemplate.permission":
+ "Нямате права за да промените шаблона за \"{slug}\"",
+ "error.page.changeTitle.empty": "Заглавието е задължително",
+ "error.page.changeTitle.permission":
+ "Не можете да промените заглавието на \"{slug}\"",
+ "error.page.create.permission": "Не можете да създадете \"{slug}\"",
+ "error.page.delete": "Страницата \"{slug}\" не може да бъде изтрита",
+ "error.page.delete.confirm": "Моля въведете името на страницата, за да потвърдите",
+ "error.page.delete.hasChildren":
+ "Страницата има подстраници и не може да бъде изтрита",
+ "error.page.delete.permission": "Не можете да изтриете \"{slug}\"",
+ "error.page.draft.duplicate":
+ "Вече съществува чернова с URL-добавка \"{slug}\"",
+ "error.page.duplicate":
+ "Страница с URL-добавка \"{slug}\" вече съществува",
+ "error.page.duplicate.permission": "You are not allowed to duplicate \"{slug}\"",
+ "error.page.notFound": "Страницата \"{slug}\" не може да бъде намерена",
+ "error.page.num.invalid":
+ "Моля въведете валидно число за сортиране. Числата не трябва да са негативни.",
+ "error.page.slug.invalid": "Моля въведете валиден URL префикс",
+ "error.page.sort.permission": "Страницата \"{slug}\" не може да бъде сортирана",
+ "error.page.status.invalid": "Моля изберете валиден статус на страницата",
+ "error.page.undefined": "\u0421\u0442\u0440\u0430\u043d\u0438\u0446\u0430\u0442\u0430 \u043d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0431\u044a\u0434\u0435 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0430",
+ "error.page.update.permission": "Не можете да обновите \"{slug}\"",
+
+ "error.section.files.max.plural":
+ "Не можете да добавяте повече от {max} файлa в секция \"{section}\"",
+ "error.section.files.max.singular":
+ "Не можете да добавяте повече от един файл в секция \"{section}\"",
+ "error.section.files.min.plural":
+ "The \"{section}\" section requires at least {min} files",
+ "error.section.files.min.singular":
+ "The \"{section}\" section requires at least one file",
+
+ "error.section.pages.max.plural":
+ "Не можете да добавяте повече от {max} страници в секция \"{section}\"",
+ "error.section.pages.max.singular":
+ "Не можете да добавяте повече от една страница в секция \"{section}\"",
+ "error.section.pages.min.plural":
+ "The \"{section}\" section requires at least {min} pages",
+ "error.section.pages.min.singular":
+ "The \"{section}\" section requires at least one page",
+
+ "error.section.notLoaded": "Секция \"{name}\" не може да бъде заредена",
+ "error.section.type.invalid": "Типът \"{type}\" на секция не е валиден",
+
+ "error.site.changeTitle.empty": "Заглавието е задължително",
+ "error.site.changeTitle.permission":
+ "Не може да променяте заглавието на сайта",
+ "error.site.update.permission": "Нямате права за да обновите сайта",
+
+ "error.template.default.notFound": "Стандартният шаблон не съществува",
+
+ "error.user.changeEmail.permission":
+ "Нямате права да промените имейла на този потребител \"{name}\"",
+ "error.user.changeLanguage.permission":
+ "Нямате права да промените езика за този потребител \"{name}\"",
+ "error.user.changeName.permission":
+ "Нямате права да промените името на този потребител \"{name}\"",
+ "error.user.changePassword.permission":
+ "Нямате права да промените паролата за този потребител \"{name}\"",
+ "error.user.changeRole.lastAdmin":
+ "Ролята на последния администратор не може да бъде променена",
+ "error.user.changeRole.permission":
+ "Нямате права да промените ролята на този потребител \"{name}\"",
+ "error.user.changeRole.toAdmin":
+ "You are not allowed to promote someone to the admin role",
+ "error.user.create.permission": "Нямате права да създадете този потребител",
+ "error.user.delete": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u044f\u0442 \u043d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0431\u044a\u0434\u0435 \u0438\u0437\u0442\u0440\u0438\u0442",
+ "error.user.delete.lastAdmin": "\u041d\u0435 \u043c\u043e\u0436\u0435\u0442\u0435 \u0434\u0430 \u0438\u0437\u0442\u0440\u0438\u0435\u0442\u0435 \u043f\u043e\u0441\u043b\u0435\u0434\u043d\u0438\u044f \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440",
+ "error.user.delete.lastUser": "Последният потребител не може да бъде изтрит",
+ "error.user.delete.permission":
+ "\u041d\u0435 \u0435 \u043f\u043e\u0437\u0432\u043e\u043b\u0435\u043d\u043e \u0434\u0430 \u0438\u0437\u0442\u0440\u0438\u0432\u0430\u0442\u0435 \u0442\u043e\u0437\u0438 \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b",
+ "error.user.duplicate":
+ "Потребител с имейл \"{email}\" вече съществува",
+ "error.user.email.invalid": "Моля въведете валиден email адрес",
+ "error.user.language.invalid": "Моля въведете валиден език",
+ "error.user.notFound": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u044f\u0442 \u043d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0431\u044a\u0434\u0435 \u043d\u0430\u043c\u0435\u0440\u0435\u043d.",
+ "error.user.password.invalid":
+ "Моля въведете валидна парола. Тя трабва да съдържа поне 8 символа.",
+ "error.user.password.notSame": "\u041c\u043e\u043b\u044f, \u043f\u043e\u0442\u0432\u044a\u0440\u0434\u0435\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u0430\u0442\u0430",
+ "error.user.password.undefined": "Потребителят няма парола",
+ "error.user.role.invalid": "Моля въведете валидна роля",
+ "error.user.update.permission":
+ "Нямате права да обновите този потребител \"{name}\"",
+
+ "error.validation.accepted": "Моля потвърдете",
+ "error.validation.alpha": "Моля въвдете символи измежду a-z",
+ "error.validation.alphanum":
+ "Моля въвдете символи измежду a-z или цифри 0-9",
+ "error.validation.between":
+ "Моля въведете стойност между \"{min}\" и \"{max}\"",
+ "error.validation.boolean": "Моля потвърдете или откажете",
+ "error.validation.contains":
+ "Моля въведете стойност, която съдържа \"{needle}\"",
+ "error.validation.date": "Моля въведете валидна дата",
+ "error.validation.date.after": "Please enter a date after {date}",
+ "error.validation.date.before": "Please enter a date before {date}",
+ "error.validation.date.between": "Please enter a date between {min} and {max}",
+ "error.validation.denied": "Моля откажете",
+ "error.validation.different": "Стойността не трябва да е \"{other}\"",
+ "error.validation.email": "Моля въведете валиден email адрес",
+ "error.validation.endswith": "Стойността трябва да завършва с \"{end\"}",
+ "error.validation.filename": "Моля въведете валидно име на файла",
+ "error.validation.in": "Моля въведете едно от следните: ({in})",
+ "error.validation.integer": "Моля въведете валидно цяло число",
+ "error.validation.ip": "Моля въведете валиден IP адрес",
+ "error.validation.less": "Моля въведете стойност по-ниска от {max}",
+ "error.validation.match": "Стойността не съвпада с очаквания модел",
+ "error.validation.max": "Please enter a value equal to or lower than {max}",
+ "error.validation.maxlength":
+ "Моля въведете по-къса стойност. (макс. {max} символа)",
+ "error.validation.maxwords": "Моля въведете не повече от {max} дума(и)",
+ "error.validation.min": "Please enter a value equal to or greater than {min}",
+ "error.validation.minlength":
+ "Моля въведете по-дълга стойност. (мин. {min} символа)",
+ "error.validation.minwords": "Моля въведете поне {min} дума(и).",
+ "error.validation.more": "Моля въведете стойност по-висока от {min}",
+ "error.validation.notcontains":
+ "Моля въведете стойност, която не съдържа \"{needle}\"",
+ "error.validation.notin":
+ "Моля не въвеждайте нито едно от следните: ({notIn})",
+ "error.validation.option": "Моля изберете валидна опция",
+ "error.validation.num": "Моля въведете валидно число",
+ "error.validation.required": "Моля въведете нещо",
+ "error.validation.same": "Моля въведете \"{other}\"",
+ "error.validation.size": "Размерът на стойността трябва да бъде \"{size}\"",
+ "error.validation.startswith": "Стойността трябва да започва с \"{start}\"",
+ "error.validation.time": "Моля въведете валидно време",
+ "error.validation.url": "Моля въведете валиден URL",
+
+ "field.required": "The field is required",
+ "field.files.empty": "Все още не са избрани файлове",
+ "field.pages.empty": "Все още не са избрани страници",
+ "field.structure.delete.confirm": "Сигурни ли сте, че искате да изтриете това вписване?",
+ "field.structure.empty": "Все още няма статии",
+ "field.users.empty": "Все още не са избрани потребители",
+
+ "file.delete.confirm":
+ "Сигурни ли сте, че искате да изтриете
{filename}?",
+
+ "files": "Файлове",
+ "files.empty": "Няма файлове",
+
+ "hour": "Hour",
+ "insert": "\u0412\u043c\u044a\u043a\u043d\u0438",
+ "install": "Инсталирай",
+
+ "installation": "Инсталация",
+ "installation.completed": "The panel has been installed",
+ "installation.disabled": "The panel installer is disabled on public servers by default. Please run the installer on a local machine or enable it with the panel.install
option.",
+ "installation.issues.accounts":
+ "Папката /site/accounts
не съществува или не позволява запис",
+ "installation.issues.content":
+ "Папката /content
и всички файлове в нея трябва да позволяват запис",
+ "installation.issues.curl": "Изисква се CURL
разширението",
+ "installation.issues.headline": "Панелът не може да бъде инсталиран",
+ "installation.issues.mbstring":
+ "Изисква се разширението MB String
",
+ "installation.issues.media":
+ "Папката /media
не съществува или няма права за запис",
+ "installation.issues.php": "Бъдете сигурни, че използвате PHP 7+
",
+ "installation.issues.server":
+ "Kirby изисква Apache
, Nginx
или Caddy
",
+ "installation.issues.sessions": "The /site/sessions
folder does not exist or is not writable",
+
+ "language": "\u0415\u0437\u0438\u043a",
+ "language.code": "Код",
+ "language.convert": "Направи по подразбиране",
+ "language.convert.confirm":
+ "
Всички подстраници също ще бъдат изтрити.",
+ "page.delete.confirm.title": "Въведи заглавие на страница за да потвърдиш",
+ "page.draft.create": "Създай чернова",
+ "page.duplicate.appendix": "Копирай",
+ "page.duplicate.files": "Copy files",
+ "page.duplicate.pages": "Copy pages",
+ "page.status": "Status",
+ "page.status.draft": "Чернова",
+ "page.status.draft.description":
+ "Страницата е в режим на чернова и е видима само за оторизирани редактори",
+ "page.status.listed": "Публично",
+ "page.status.listed.description": "Страницата е публична за всички",
+ "page.status.unlisted": "Скрит",
+ "page.status.unlisted.description": "Страницата е достъпна само чрез URL",
+
+ "pages": "Страници",
+ "pages.empty": "Все още няма страници",
+ "pages.status.draft": "Drafts",
+ "pages.status.listed": "Published",
+ "pages.status.unlisted": "Скрит",
+
+ "pagination.page": "Страница",
+
+ "password": "\u041f\u0430\u0440\u043e\u043b\u0430",
+ "pixel": "Пиксел",
+ "prev": "Previous",
+ "remove": "Премахни",
+ "rename": "Преименувай",
+ "replace": "\u0417\u0430\u043c\u0435\u0441\u0442\u0438",
+ "retry": "\u041e\u043f\u0438\u0442\u0430\u0439 \u043f\u0430\u043a",
+ "revert": "\u041e\u0442\u043c\u0435\u043d\u0438",
+
+ "role": "\u0420\u043e\u043b\u044f",
+ "role.admin.description": "The admin has all rights",
+ "role.admin.title": "Admin",
+ "role.all": "Всички",
+ "role.empty": "Не съществуват потребители с тази роля",
+ "role.description.placeholder": "Липсва описание",
+ "role.nobody.description": "This is a fallback role without any permissions",
+ "role.nobody.title": "Nobody",
+
+ "save": "\u0417\u0430\u043f\u0438\u0448\u0438",
+ "search": "Търси",
+
+ "section.required": "The section is required",
+
+ "select": "Избери",
+ "settings": "Настройки",
+ "size": "Размер",
+ "slug": "URL-\u0434\u043e\u0431\u0430\u0432\u043a\u0430",
+ "sort": "Сортирай",
+ "title": "Заглавие",
+ "template": "Образец",
+ "today": "Днес",
+
+ "toolbar.button.code": "Код",
+ "toolbar.button.bold": "\u041f\u043e\u043b\u0443\u0447\u0435\u0440 \u0448\u0440\u0438\u0444\u0442",
+ "toolbar.button.email": "Email",
+ "toolbar.button.headings": "Заглавия",
+ "toolbar.button.heading.1": "Заглавие 1",
+ "toolbar.button.heading.2": "Заглавие 2",
+ "toolbar.button.heading.3": "Заглавие 3",
+ "toolbar.button.italic": "\u041d\u0430\u043a\u043b\u043e\u043d\u0435\u043d \u0448\u0440\u0438\u0444\u0442",
+ "toolbar.button.file": "Файл",
+ "toolbar.button.file.select": "Select a file",
+ "toolbar.button.file.upload": "Upload a file",
+ "toolbar.button.link": "\u0412\u0440\u044a\u0437\u043a\u0430",
+ "toolbar.button.ol": "Подреден списък",
+ "toolbar.button.ul": "Списък",
+
+ "translation.author": "Kirby екип",
+ "translation.direction": "ltr",
+ "translation.name": "Български",
+ "translation.locale": "bg_BG",
+
+ "upload": "Прикачи",
+ "upload.error.cantMove": "The uploaded file could not be moved",
+ "upload.error.cantWrite": "Failed to write file to disk",
+ "upload.error.default": "The file could not be uploaded",
+ "upload.error.extension": "File upload stopped by extension",
+ "upload.error.formSize": "The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the form",
+ "upload.error.iniPostSize": "The uploaded file exceeds the post_max_size directive in php.ini",
+ "upload.error.iniSize": "The uploaded file exceeds the upload_max_filesize directive in php.ini",
+ "upload.error.noFile": "No file was uploaded",
+ "upload.error.noFiles": "No files were uploaded",
+ "upload.error.partial": "The uploaded file was only partially uploaded",
+ "upload.error.tmpDir": "Missing a temporary folder",
+ "upload.errors": "Грешка",
+ "upload.progress": "Uploading…",
+
+ "url": "Url",
+ "url.placeholder": "https://example.com",
+
+ "user": "Потребител",
+ "user.blueprint":
+ "Можете да дефинирате допълнителни секции и полета на форми за тази потребителска роля в /site/blueprints/users/{role}.yml",
+ "user.changeEmail": "Промени email",
+ "user.changeLanguage": "Промени език",
+ "user.changeName": "Преименувай този потребител",
+ "user.changePassword": "Промени парола",
+ "user.changePassword.new": "Нова парола",
+ "user.changePassword.new.confirm": "Потвърдете новата парола...",
+ "user.changeRole": "Променете роля",
+ "user.changeRole.select": "Изберете нова роля",
+ "user.create": "Добавете нов потребител",
+ "user.delete": "Изтрийте потребителя",
+ "user.delete.confirm":
+ "Сигурни ли сте, че искате да изтриете
{email}?",
+
+ "users": "Потребители",
+
+ "version": "\u0412\u0435\u0440\u0441\u0438\u044f \u043d\u0430 Kirby",
+
+ "view.account": "\u0412\u0430\u0448\u0438\u044f \u0430\u043a\u0430\u0443\u043d\u0442",
+ "view.installation": "\u0418\u043d\u0441\u0442\u0430\u043b\u0430\u0446\u0438\u044f",
+ "view.settings": "Настройки",
+ "view.site": "Сайт",
+ "view.users": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0438",
+
+ "welcome": "Добре дошли",
+ "year": "Year"
+}
diff --git a/kirby/i18n/translations/ca.json b/kirby/i18n/translations/ca.json
new file mode 100755
index 0000000..433bee5
--- /dev/null
+++ b/kirby/i18n/translations/ca.json
@@ -0,0 +1,481 @@
+{
+ "add": "Afegir",
+ "avatar": "Imatge del perfil",
+ "back": "Tornar",
+ "cancel": "Cancel\u00b7lar",
+ "change": "Canviar",
+ "close": "Tancar",
+ "confirm": "Ok",
+ "copy": "Copiar",
+ "create": "Crear",
+
+ "date": "Data",
+ "date.select": "Selecciona una data",
+
+ "day": "Dia",
+ "days.fri": "dv.",
+ "days.mon": "dl.",
+ "days.sat": "ds.",
+ "days.sun": "dg.",
+ "days.thu": "dj.",
+ "days.tue": "dt.",
+ "days.wed": "dc.",
+
+ "delete": "Eliminar",
+ "dimensions": "Dimensions",
+ "disabled": "Desactivat",
+ "discard": "Descartar",
+ "download": "Descarregar",
+ "duplicate": "Duplicar",
+ "edit": "Editar",
+
+ "dialog.files.empty": "No hi ha cap fitxer per seleccionar",
+ "dialog.pages.empty": "No hi ha cap pàgina per seleccionar",
+ "dialog.users.empty": "No hi ha cap usuari per seleccionar",
+
+ "email": "Email",
+ "email.placeholder": "mail@exemple.com",
+
+ "error.access.login": "Inici de sessió no vàlid",
+ "error.access.panel": "No tens permís per accedir al panell",
+ "error.access.view": "No tens accés a aquesta part del tauler",
+
+ "error.avatar.create.fail": "No s'ha pogut carregar la imatge del perfil",
+ "error.avatar.delete.fail": "La imatge del perfil no s'ha pogut eliminar",
+ "error.avatar.dimensions.invalid":
+ "Mantingueu l'amplada i l'alçada de la imatge de perfil de menys de 3000 píxels",
+ "error.avatar.mime.forbidden":
+ "La imatge del perfil ha de ser fitxers JPEG o PNG",
+
+ "error.blueprint.notFound": "No s'ha potgut carregar el blueprint \"{name}\"",
+
+ "error.email.preset.notFound": "No es pot trobar la configuració de correu electrònic \"{name}\"",
+
+ "error.field.converter.invalid": "Convertidor no vàlid \"{converter}\"",
+
+ "error.file.changeName.empty": "El nom no pot estar buit",
+ "error.file.changeName.permission":
+ "No tens permís per canviar el nom de \"{filename}\"",
+ "error.file.duplicate": "Ja existeix un fitxer amb el nom \"{filename}\"",
+ "error.file.extension.forbidden":
+ "L'extensió de l'arxiu \"{extension}\" no està permesa",
+ "error.file.extension.missing":
+ "Falta l'extensió de l'arxiu \"{filename}\"",
+ "error.file.maxheight": "L'alçada de la imatge no ha de ser superior a {height} píxels",
+ "error.file.maxsize": "El fitxer és massa gran",
+ "error.file.maxwidth": "L'amplada de la imatge no ha de ser superior a {width} píxels",
+ "error.file.mime.differs":
+ "L'arxiu carregat ha ha de ser del mateix tipus de mime \"{mime}\"",
+ "error.file.mime.forbidden": "El tipus de mitjà \"{mime}\" no està permès",
+ "error.file.mime.invalid": "Mime type no vàlid: {mime}",
+ "error.file.mime.missing":
+ "El tipus de suport per a \"{filename}\" no es pot detectar",
+ "error.file.minheight": "L'alçada de la imatge ha de ser com a mínim de {height} píxels",
+ "error.file.minsize": "El fitxer és massa petit",
+ "error.file.minwidth": "L'amplada de la imatge ha de ser com a mínim de {width} píxels",
+ "error.file.name.missing": "El nom del fitxer no pot estar buit",
+ "error.file.notFound": "L'arxiu \"{filename}\" no s'ha trobat",
+ "error.file.orientation": "L’orientació de la imatge ha de ser \"{orientation}\"",
+ "error.file.type.forbidden": "No tens permís per penjar fitxers {type}",
+ "error.file.undefined": "L'arxiu no s'ha trobat",
+
+ "error.form.incomplete": "Si us plau, corregeix els errors del formulari ...",
+ "error.form.notSaved": "No s'ha pogut desar el formulari",
+
+ "error.language.code": "Introdueix un codi vàlid per a l’idioma",
+ "error.language.duplicate": "L'idioma ja existeix",
+ "error.language.name": "Introdueix un nom vàlid per a l'idioma",
+
+ "error.license.format": "Introduïu una clau de llicència vàlida",
+ "error.license.email": "Si us plau, introdueix una adreça de correu electrònic vàlida",
+ "error.license.verification": "No s’ha pogut verificar la llicència",
+
+ "error.page.changeSlug.permission":
+ "No teniu permís per canviar l'apèndix d'URL per a \"{slug}\"",
+ "error.page.changeStatus.incomplete":
+ "La pàgina té errors i no es pot publicar",
+ "error.page.changeStatus.permission":
+ "No es pot canviar l'estat d'aquesta pàgina",
+ "error.page.changeStatus.toDraft.invalid":
+ "La pàgina \"{slug}\" no es pot convertir en un esborrany",
+ "error.page.changeTemplate.invalid":
+ "La plantilla per a la pàgina \"{slug}\" no es pot canviar",
+ "error.page.changeTemplate.permission":
+ "No tens permís per canviar la plantilla per \"{slug}\"",
+ "error.page.changeTitle.empty": "El títol no pot estar buit",
+ "error.page.changeTitle.permission":
+ "No tens permís per canviar el títol de \"{slug}\"",
+ "error.page.create.permission": "No tens permís per crear \"{slug}\"",
+ "error.page.delete": "La pàgina \"{slug}\" no es pot esborrar",
+ "error.page.delete.confirm": "Si us plau, introdueix el títol de la pàgina per confirmar",
+ "error.page.delete.hasChildren":
+ "La pàgina té subpàgines i no es pot esborrar",
+ "error.page.delete.permission": "No tens permís per esborrar \"{slug}\"",
+ "error.page.draft.duplicate":
+ "Ja existeix un esborrany de pàgina amb l'apèndix d'URL \"{slug}\"",
+ "error.page.duplicate":
+ "Ja existeix una pàgina amb l'apèndix d'URL \"{slug}\"",
+ "error.page.duplicate.permission": "No tens permís per duplicar \"{slug}\"",
+ "error.page.notFound": "La pàgina \"{slug}\" no s'ha trobat",
+ "error.page.num.invalid":
+ "Si us plau, introdueix un número d 'ordenació vàlid. Els números no poden ser negatius.",
+ "error.page.slug.invalid": "Introduïu un prefix d'URL vàlid",
+ "error.page.sort.permission": "La pàgina \"{slug}\" no es pot ordenar",
+ "error.page.status.invalid": "Si us plau, estableix un estat de pàgina vàlid",
+ "error.page.undefined": "La p\u00e0gina no s'ha trobat",
+ "error.page.update.permission": "No tens permís per actualitzar \"{slug}\"",
+
+ "error.section.files.max.plural":
+ "No has d'afegir més de {max} fitxers a la secció \"{section}\"",
+ "error.section.files.max.singular":
+ "No podeu afegir més d'un fitxer a la secció \"{section}\"",
+ "error.section.files.min.plural":
+ "La secció \"{section}\" requereix almenys {min} fitxer",
+ "error.section.files.min.singular":
+ "La secció \"{section}\" requereix almenys un fitxer",
+
+ "error.section.pages.max.plural":
+ "No heu d'afegir més de {max} pàgines a la secció \"{section}\"",
+ "error.section.pages.max.singular":
+ "No podeu afegir més d'una pàgina a la secció \"{section}\"",
+ "error.section.pages.min.plural":
+ "La secció \"{section}\" requereix almenys {min} pàgines",
+ "error.section.pages.min.singular":
+ "La secció \"{section}\" requereix almenys una pàgina",
+
+ "error.section.notLoaded": "No s'ha pogut carregar la secció \"{name}\"",
+ "error.section.type.invalid": "La secció tipus \"{type}\" no és vàlida",
+
+ "error.site.changeTitle.empty": "El títol no pot estar buit",
+ "error.site.changeTitle.permission":
+ "No tens permís per canviar el títol del lloc web",
+ "error.site.update.permission": "No tens permís per actualitzar el lloc web",
+
+ "error.template.default.notFound": "La plantilla predeterminada no existeix",
+
+ "error.user.changeEmail.permission":
+ "No tens permís per canviar el correu electrònic per a l'usuari \"{name}\"",
+ "error.user.changeLanguage.permission":
+ "No tens permís per canviar l'idioma de l'usuari \"{name}\"",
+ "error.user.changeName.permission":
+ "No tens permís per canviar el nom de l'usuari \"{name}\"",
+ "error.user.changePassword.permission":
+ "No tens permís per canviar la contrasenya de l'usuari \"{name}\"",
+ "error.user.changeRole.lastAdmin":
+ "El rol del darrer administrador no es pot canviar",
+ "error.user.changeRole.permission":
+ "No tens permís per canviar el rol de l'usuari \"{name}\"",
+ "error.user.changeRole.toAdmin":
+ "No tens permís per promocionar algú al rol d’administrador",
+ "error.user.create.permission": "No tens permís per crear aquest usuari",
+ "error.user.delete": "L'usuari \"{name}\" no es pot eliminar",
+ "error.user.delete.lastAdmin": "No es pot eliminar l'\u00faltim administrador",
+ "error.user.delete.lastUser": "El darrer usuari no es pot eliminar",
+ "error.user.delete.permission":
+ "No pots eliminar l'usuari \"{name}\"",
+ "error.user.duplicate":
+ "Ja existeix un usuari amb l'adreça electrònica \"{email}\"",
+ "error.user.email.invalid": "Si us plau, introdueix una adreça de correu electrònic vàlida",
+ "error.user.language.invalid": "Introduïu un idioma vàlid",
+ "error.user.notFound": "L'usuari \"{name}\" no s'ha trobat",
+ "error.user.password.invalid":
+ "Introduïu una contrasenya vàlida. Les contrasenyes han de tenir com a mínim 8 caràcters.",
+ "error.user.password.notSame": "Les contrasenyes no coincideixen",
+ "error.user.password.undefined": "L'usuari no té una contrasenya",
+ "error.user.role.invalid": "Si us plau, introdueix un rol vàlid",
+ "error.user.update.permission":
+ "No tens permís per actualitzar l'usuari \"{name}\"",
+
+ "error.validation.accepted": "Si us plau confirma",
+ "error.validation.alpha": "Si us plau, introdueix únicament caràcters entre a-z",
+ "error.validation.alphanum":
+ "Si us plau, introdueix únicament caràcters entre a-z o números de 0-9",
+ "error.validation.between":
+ "Introdueix un valor entre \"{min}\" i \"{max}\"",
+ "error.validation.boolean": "Si us plau confirma o denega",
+ "error.validation.contains":
+ "Si us plau, introduïu un valor que contingui \"{needle}\"",
+ "error.validation.date": "Si us plau, introdueix una data vàlida",
+ "error.validation.date.after": "Introdueix una data posterior {date}",
+ "error.validation.date.before": "Introdueix una data anterior {date}",
+ "error.validation.date.between": "Introdueix una data entre {min} i {max}",
+ "error.validation.denied": "Si us plau, denegui",
+ "error.validation.different": "El valor no ha de ser \"{other}\"",
+ "error.validation.email": "Si us plau, introdueix una adreça de correu electrònic vàlida",
+ "error.validation.endswith": "El valor ha de finalitzar amb \"{end}\"",
+ "error.validation.filename": "Si us plau, introdueix un nom de fitxer vàlid",
+ "error.validation.in": "Si us plau, introduïu una de les opcions següents: ({in})",
+ "error.validation.integer": "Si us plau, introduïu un nombre enter vàlid",
+ "error.validation.ip": "Si us plau, introduïu una adreça IP vàlida",
+ "error.validation.less": "Si us plau, introduïu un valor inferior a {max}",
+ "error.validation.match": "El valor no coincideix amb el patró esperat",
+ "error.validation.max": "Si us plau, introduïu un valor igual o inferior a {max}",
+ "error.validation.maxlength":
+ "Si us plau, introduïu un valor més curt. (màxim {max} caràcters)",
+ "error.validation.maxwords": "Si us plau, introduïu no més de {max} paraula(es)",
+ "error.validation.min": "Si us plau, introduïu un valor igual o superior a {min}",
+ "error.validation.minlength":
+ "Si us plau, introduïu un valor més llarg. (min. {min} caràcters)",
+ "error.validation.minwords": "Si us plau, introduïu almenys {min} paraula(es)",
+ "error.validation.more": "Si us plau, introduïu un valor més gran que {min}",
+ "error.validation.notcontains":
+ "Introduïu un valor que no contingui \"{needle}\"",
+ "error.validation.notin":
+ "Si us plau, no introduïu cap d'aquests elements: ({notIn})",
+ "error.validation.option": "Si us plau, seleccioneu una opció vàlida",
+ "error.validation.num": "Si us plau, introduïu un número vàlid",
+ "error.validation.required": "Si us plau, introduïu alguna cosa",
+ "error.validation.same": "Si us plau, introduïu \"{other}\"",
+ "error.validation.size": "La mida del valor ha de ser \"{size}\"",
+ "error.validation.startswith": "El valor ha de començar amb \"{start}\"",
+ "error.validation.time": "Si us plau, introduïu una hora vàlida",
+ "error.validation.url": "Si us plau, introduïu una URL vàlida",
+
+ "field.required": "El camp és obligatori",
+ "field.files.empty": "Encara no hi ha cap fitxer seleccionat",
+ "field.pages.empty": "Encara no s'ha seleccionat cap pàgina",
+ "field.structure.delete.confirm": "Segur que voleu eliminar aquesta fila?",
+ "field.structure.empty": "Encara no hi ha entrades.",
+ "field.users.empty": "Encara no s'ha seleccionat cap usuari",
+
+ "file.delete.confirm":
+ "Esteu segurs d'eliminar
{filename}?",
+
+ "files": "Arxius",
+ "files.empty": "Encara no hi ha fitxers",
+
+ "hour": "Hora",
+ "insert": "Insertar",
+ "install": "Instal·lar",
+
+ "installation": "Instal·lació",
+ "installation.completed": "S'ha instal·lat el panell",
+ "installation.disabled": "L'instal·lador del panell està desactivat per defecte als servidors públics. Si us plau, executeu l'instal·lador en una màquina local o habiliteu-lo amb l'opció panel.install
",
+ "installation.issues.accounts":
+ "La carpeta /site/accounts
no existeix o no es pot escriure",
+ "installation.issues.content":
+ "La carpeta /content no existeix o no es pot escriure",
+ "installation.issues.curl": "Es requereix l'extensió
CURL
",
+ "installation.issues.headline": "El panell no es pot instal·lar",
+ "installation.issues.mbstring":
+ "Es requereix l'extensió de MB String
",
+ "installation.issues.media":
+ "La carpeta /media
no existeix o no es pot escriure",
+ "installation.issues.php": "Assegureu-vos d'utilitzar PHP 7+
",
+ "installation.issues.server":
+ "Kirby requereix Apache
, Nginx
o Caddy
",
+ "installation.issues.sessions": "La carpeta /site/sessions
no existeix o no es pot escriure",
+
+ "language": "Idioma",
+ "language.code": "Codi",
+ "language.convert": "Fer per defecte",
+ "language.convert.confirm":
+ "
Totes les subpàgines també s'eliminaran.",
+ "page.delete.confirm.title": "Introduïu el títol de la pàgina per confirmar",
+ "page.draft.create": "Crear un esborrany",
+ "page.duplicate.appendix": "Copiar",
+ "page.duplicate.files": "Copiar fitxers",
+ "page.duplicate.pages": "Copiar pàgines",
+ "page.status": "Estat",
+ "page.status.draft": "Esborrany",
+ "page.status.draft.description":
+ "La pàgina està en mode d'esborrany i només és visible per als editors registrats",
+ "page.status.listed": "Públic",
+ "page.status.listed.description": "La pàgina és pública per a tothom",
+ "page.status.unlisted": "Sense classificar",
+ "page.status.unlisted.description": "La pàgina només es pot accedir a través de l'URL",
+
+ "pages": "Pàgines",
+ "pages.empty": "Encara no hi ha pàgines",
+ "pages.status.draft": "Esborranys",
+ "pages.status.listed": "Publicat",
+ "pages.status.unlisted": "Sense classificar",
+
+ "pagination.page": "Pàgina",
+
+ "password": "Contrasenya",
+ "pixel": "Pixel",
+ "prev": "Anterior",
+ "remove": "Eliminar",
+ "rename": "Canviar el nom",
+ "replace": "Reempla\u00e7ar",
+ "retry": "Reintentar",
+ "revert": "Revertir",
+
+ "role": "Rol",
+ "role.admin.description": "L’administrador té tots els permisos",
+ "role.admin.title": "Administrador",
+ "role.all": "Tots",
+ "role.empty": "No hi ha usuaris amb aquest rol",
+ "role.description.placeholder": "Sense descripció",
+ "role.nobody.description": "Aquest és un rol per defecte sense permisos",
+ "role.nobody.title": "Ningú",
+
+ "save": "Desar",
+ "search": "Cercar",
+
+ "section.required": "La secció és obligatòria",
+
+ "select": "Seleccionar",
+ "settings": "Configuració",
+ "size": "Tamany",
+ "slug": "URL-ap\u00e8ndix",
+ "sort": "Ordenar",
+ "title": "Títol",
+ "template": "Plantilla",
+ "today": "Avui",
+
+ "toolbar.button.code": "Codi",
+ "toolbar.button.bold": "Negreta",
+ "toolbar.button.email": "Email",
+ "toolbar.button.headings": "Encapçalaments",
+ "toolbar.button.heading.1": "Encapçalament 1",
+ "toolbar.button.heading.2": "Encapçalament 2",
+ "toolbar.button.heading.3": "Encapçalament 3",
+ "toolbar.button.italic": "Cursiva",
+ "toolbar.button.file": "Arxiu",
+ "toolbar.button.file.select": "Selecciona un fitxer",
+ "toolbar.button.file.upload": "Carrega un fitxer",
+ "toolbar.button.link": "Enlla\u00e7",
+ "toolbar.button.ol": "Llista ordenada",
+ "toolbar.button.ul": "Llista de vinyetes",
+
+ "translation.author": "Equip Kirby",
+ "translation.direction": "ltr",
+ "translation.name": "Catalan",
+ "translation.locale": "ca_ES",
+
+ "upload": "Carregar",
+ "upload.error.cantMove": "El fitxer carregat no s'ha pogut moure",
+ "upload.error.cantWrite": "No s'ha pogut escriure el fitxer al disc",
+ "upload.error.default": "No s'ha pogut carregar el fitxer",
+ "upload.error.extension": "La càrrega del fitxer s'ha aturat per l'extensió",
+ "upload.error.formSize": "El fitxer carregat supera la directiva MAX_FILE_SIZE especificada en el formulari",
+ "upload.error.iniPostSize": "El fitxer carregat supera la directiva post_max_size especifiada al php.ini",
+ "upload.error.iniSize": "El fitxer carregat supera la directiva upload_max_filesize especifiada al php.ini",
+ "upload.error.noFile": "No s'ha carregat cap fitxer",
+ "upload.error.noFiles": "No s'ha penjat cap fitxer",
+ "upload.error.partial": "El fitxer carregat només s'ha carregat parcialment",
+ "upload.error.tmpDir": "Falta una carpeta temporal",
+ "upload.errors": "Error",
+ "upload.progress": "Carregant...",
+
+ "url": "Url",
+ "url.placeholder": "https://example.com",
+
+ "user": "Usuari",
+ "user.blueprint":
+ "Podeu definir seccions addicionals i camps de formulari per a aquest rol d'usuari a /site/blueprints/users/{role}.yml",
+ "user.changeEmail": "Canviar e-mail",
+ "user.changeLanguage": "Canviar idioma",
+ "user.changeName": "Canviar el nom d'aquest usuari",
+ "user.changePassword": "Canviar contrasenya",
+ "user.changePassword.new": "Nova contrasenya",
+ "user.changePassword.new.confirm": "Confirma la nova contrasenya ...",
+ "user.changeRole": "Canviar el rol",
+ "user.changeRole.select": "Seleccionar un nou rol",
+ "user.create": "Afegir un nou usuari",
+ "user.delete": "Eliminar aquest usuari",
+ "user.delete.confirm":
+ "Segur que voleu eliminar
{email}?",
+
+ "users": "Usuaris",
+
+ "version": "Versi\u00f3 de Kirby",
+
+ "view.account": "La teva compta",
+ "view.installation": "Instal·lació",
+ "view.settings": "Configuració",
+ "view.site": "Lloc web",
+ "view.users": "Usuaris",
+
+ "welcome": "Benvinguda",
+ "year": "Any"
+}
diff --git a/kirby/i18n/translations/cs.json b/kirby/i18n/translations/cs.json
new file mode 100755
index 0000000..0b40e54
--- /dev/null
+++ b/kirby/i18n/translations/cs.json
@@ -0,0 +1,481 @@
+{
+ "add": "P\u0159idat",
+ "avatar": "Profilov\u00fd obr\u00e1zek",
+ "back": "Zpět",
+ "cancel": "Zru\u0161it",
+ "change": "Zm\u011bnit",
+ "close": "Zav\u0159it",
+ "confirm": "Ok",
+ "copy": "Kopírovat",
+ "create": "Vytvořit",
+
+ "date": "Datum",
+ "date.select": "Vyberte datum",
+
+ "day": "Den",
+ "days.fri": "p\u00e1",
+ "days.mon": "po",
+ "days.sat": "so",
+ "days.sun": "ne",
+ "days.thu": "\u010dt",
+ "days.tue": "\u00fat",
+ "days.wed": "st",
+
+ "delete": "Smazat",
+ "dimensions": "Rozměry",
+ "disabled": "Zakázáno",
+ "discard": "Zahodit",
+ "download": "Stáhnout",
+ "duplicate": "Duplikovat",
+ "edit": "Upravit",
+
+ "dialog.files.empty": "Žádné soubory k výběru",
+ "dialog.pages.empty": "Žádné stránky k výběru",
+ "dialog.users.empty": "Žádní uživatelé k výběru",
+
+ "email": "Email",
+ "email.placeholder": "mail@example.com",
+
+ "error.access.login": "Neplatné přihlášení",
+ "error.access.panel": "Nemáte povoleno vstoupit do panelu",
+ "error.access.view": "Nejste oprávněni vstoupit do této části panelu.",
+
+ "error.avatar.create.fail": "Nebylo možné nahrát profilový obrázek",
+ "error.avatar.delete.fail": "Nebylo mo\u017en\u00e9 smazat profilov\u00fd obr\u00e1zek",
+ "error.avatar.dimensions.invalid":
+ "Výšku a šířka profilového obrázku by měla být pod 3000 pixelů",
+ "error.avatar.mime.forbidden":
+ "Profilový obrázek musí být ve formátu JPEG nebo PNG",
+
+ "error.blueprint.notFound": "Nelze načíst blueprint \"{name}\" ",
+
+ "error.email.preset.notFound": "Nelze nalézt emailové přednastavení \"{name}\"",
+
+ "error.field.converter.invalid": "Neplatný konvertor \"{converter}\"",
+
+ "error.file.changeName.empty": "Toto jméno nesmí být prázdné",
+ "error.file.changeName.permission":
+ "Nemáte povoleno změnit jméno souboru \"{filename}\"",
+ "error.file.duplicate": "Soubor s názvem \"{filename}\" již existuje",
+ "error.file.extension.forbidden":
+ "Přípona souboru \"{extension}\" není povolena",
+ "error.file.extension.missing":
+ "Nem\u016f\u017eete nahr\u00e1t soubor bez p\u0159\u00edpony",
+ "error.file.maxheight": "The height of the image must not exceed {height} pixels",
+ "error.file.maxsize": "The file is too large",
+ "error.file.maxwidth": "The width of the image must not exceed {width} pixels",
+ "error.file.mime.differs":
+ "Nahraný soubor musí být stejného typu \"{mime}\"",
+ "error.file.mime.forbidden": "Soubor typu \"{mime}\" není povolený",
+ "error.file.mime.invalid": "Invalid mime type: {mime}",
+ "error.file.mime.missing":
+ "Nelze rozeznat mime typ souboru \"{filename}\"",
+ "error.file.minheight": "The height of the image must be at least {height} pixels",
+ "error.file.minsize": "The file is too small",
+ "error.file.minwidth": "The width of the image must be at least {width} pixels",
+ "error.file.name.missing": "Název souboru nesmí být prázdný",
+ "error.file.notFound": "Soubor se nepoda\u0159ilo nal\u00e9zt",
+ "error.file.orientation": "The orientation of the image must be \"{orientation}\"",
+ "error.file.type.forbidden": "Nemáte povoleno nahrávat soubory typu {type} ",
+ "error.file.undefined": "Soubor se nepoda\u0159ilo nal\u00e9zt",
+
+ "error.form.incomplete": "Prosím opravte všechny chyby ve formuláři",
+ "error.form.notSaved": "Formulář nemohl být uložen",
+
+ "error.language.code": "Zadejte prosím platný kód jazyka",
+ "error.language.duplicate": "Jazyk již existuje",
+ "error.language.name": "Zadejte prosím platné jméno jazyka",
+
+ "error.license.format": "Zadejte prosím platné licenční číslo",
+ "error.license.email": "Zadejte prosím platnou emailovou adresu",
+ "error.license.verification": "Licenci nelze ověřit",
+
+ "error.page.changeSlug.permission":
+ "Nem\u016f\u017eete zm\u011bnit URL t\u00e9to str\u00e1nky",
+ "error.page.changeStatus.incomplete":
+ "Stránka obsahuje chyby a nemohla být zveřejněna",
+ "error.page.changeStatus.permission":
+ "Status této stránky nelze změnit",
+ "error.page.changeStatus.toDraft.invalid":
+ "Stránka \"{slug}\" nemůže být převedena na koncept",
+ "error.page.changeTemplate.invalid":
+ "Šablonu stránky \"{slug}\" nelze změnit",
+ "error.page.changeTemplate.permission":
+ "Nemáte dovoleno změnit šablonu stránky \"{slug}\"",
+ "error.page.changeTitle.empty": "Titulek nesmí být prázdný",
+ "error.page.changeTitle.permission":
+ "Nemáte dovoleno změnit titulek stránky \"{slug}\"",
+ "error.page.create.permission": "Nemáte dovoleno vytvořit \"{slug}\"",
+ "error.page.delete": "Stránku \"{slug}\" nelze vymazat",
+ "error.page.delete.confirm": "Pro potvrzení prosím zadejte titulek stránky",
+ "error.page.delete.hasChildren":
+ "Stránka má podstránky, nemůže být vymazána",
+ "error.page.delete.permission": "Nemáte dovoleno odstranit \"{slug}\"",
+ "error.page.draft.duplicate":
+ "Koncept stránky, který obsahuje v adrese URL \"{slug}\" již existuje ",
+ "error.page.duplicate":
+ "Stránka, která v adrese URL obsahuje \"{slug}\" již existuje",
+ "error.page.duplicate.permission": "Nemáte dovoleno duplikovat \"{slug}\"",
+ "error.page.notFound": "Str\u00e1nku se nepoda\u0159ilo nal\u00e9zt.",
+ "error.page.num.invalid":
+ "Zadejte prosím platné pořadové číslo. Čísla nesmí být záporná.",
+ "error.page.slug.invalid": "Zadejte prosím platnou předponu URL",
+ "error.page.sort.permission": "Stránce \"{slug}\" nelze změnit pořadí",
+ "error.page.status.invalid": "Nastavte prosím platný status stránky",
+ "error.page.undefined": "Str\u00e1nku se nepoda\u0159ilo nal\u00e9zt.",
+ "error.page.update.permission": "Nemáte dovoleno upravit \"{slug}\"",
+
+ "error.section.files.max.plural":
+ "Sekce \"{section}\" nesmí obsahovat více jak {max} souborů",
+ "error.section.files.max.singular":
+ "Sekce \"{section}\" může obsahovat nejvýše jeden soubor",
+ "error.section.files.min.plural":
+ "Sekce \"{section}\" vyžaduje nejméně {min} souborů",
+ "error.section.files.min.singular":
+ "Sekce \"{section}\" vyžaduje alespoň jeden soubor",
+
+ "error.section.pages.max.plural":
+ "Sekce \"{section}\" nesmí obsahovat více jak {max} stránek",
+ "error.section.pages.max.singular":
+ "Sekce \"{section}\" může obsahovat nejvýše jednu stránku",
+ "error.section.pages.min.plural":
+ "Sekce \"{section}\" vyžaduje alespoň {min} stránek",
+ "error.section.pages.min.singular":
+ "Sekce \"{section}\" vyžaduje alespoň jednu stránku",
+
+ "error.section.notLoaded": "Nelze načíst sekci \"{name}\"",
+ "error.section.type.invalid": "Typ sekce \"{type}\" není platný",
+
+ "error.site.changeTitle.empty": "Titulek nesmí být prázdný",
+ "error.site.changeTitle.permission":
+ "Nemáte dovoleno změnit titulek stránky",
+ "error.site.update.permission": "Nemáte dovoleno upravit stránku",
+
+ "error.template.default.notFound": "Výchozí šablona neexistuje",
+
+ "error.user.changeEmail.permission":
+ "Nemáte dovoleno měnit email uživatele \"{name}\"",
+ "error.user.changeLanguage.permission":
+ "Nemáte dovoleno změnit jazyk uživatele \"{name}\"",
+ "error.user.changeName.permission":
+ "Nemáte dovoleno změnit jméno uživatele \"{name}\"",
+ "error.user.changePassword.permission":
+ "Nemáte dovoleno změnit heslo uživatele \"{name}\"",
+ "error.user.changeRole.lastAdmin":
+ "Role posledního administrátora nemůže být změněna",
+ "error.user.changeRole.permission":
+ "Nemáte dovoleno změnit roli uživatele \"{name}\"",
+ "error.user.changeRole.toAdmin":
+ "Nemáte dovoleno povýšit uživatele do role administrátora.",
+ "error.user.create.permission": "Nemáte dovoleno vytvořit tohoto uživatele",
+ "error.user.delete": "U\u017eivatel nemohl b\u00fdt smaz\u00e1n",
+ "error.user.delete.lastAdmin": "Nem\u016f\u017eete smazat posledn\u00edho administr\u00e1tora",
+ "error.user.delete.lastUser": "Poslední uživatel nemůže být smazán",
+ "error.user.delete.permission":
+ "Nem\u00e1te dovoleno smazat tohoto u\u017eivatele",
+ "error.user.duplicate":
+ "Uživatel s emailovou adresou \"{email}\" již existuje",
+ "error.user.email.invalid": "Zadejte prosím platnou emailovou adresu",
+ "error.user.language.invalid": "Zadejte prosím platný jazyk",
+ "error.user.notFound": "U\u017eivatele se nepoda\u0159ilo nal\u00e9zt",
+ "error.user.password.invalid":
+ "Zadejte prosím platné heslo. Heslo musí být dlouhé alespoň 8 znaků.",
+ "error.user.password.notSame": "Pros\u00edm potvr\u010fte heslo",
+ "error.user.password.undefined": "Uživatel nemá nastavené heslo.",
+ "error.user.role.invalid": "Zadejte prosím platnou roli",
+ "error.user.update.permission":
+ "Nemáte dovoleno upravit uživatele \"{name}\"",
+
+ "error.validation.accepted": "Potvrďte prosím",
+ "error.validation.alpha": "Zadávejte prosím pouze znaky v rozmezí a-z",
+ "error.validation.alphanum":
+ "Zadávejte prosím pouze znaky v rozmezí a-z nebo čísla v rozmezí 0-9",
+ "error.validation.between":
+ "Zadejte prosím hodnotu mez \"{min}\" a \"{max}\"",
+ "error.validation.boolean": "Potvrďte prosím, nebo odmítněte",
+ "error.validation.contains":
+ "Zadejte prosím hodnotu, která obsahuje \"{needle}\"",
+ "error.validation.date": "Zadejte prosím platné datum",
+ "error.validation.date.after": "Zadejte prosím datum po {date}",
+ "error.validation.date.before": "Zadejte prosím datum před {date}",
+ "error.validation.date.between": "Zadejte prosím datum mezi {min} a {max}",
+ "error.validation.denied": "Prosím, odmítněte",
+ "error.validation.different": "Hodnota nesmí být \"{other}\"",
+ "error.validation.email": "Zadejte prosím platnou emailovou adresu",
+ "error.validation.endswith": "Hodnota nesmí končit \"{end}\"",
+ "error.validation.filename": "Zadejte prosím platný název souboru",
+ "error.validation.in": "Zadejte prosím některou z následujíích hodnot: ({in})",
+ "error.validation.integer": "Zadejte prosím platné celé číslo",
+ "error.validation.ip": "Zadejte prosím platnou IP adresu",
+ "error.validation.less": "Zadejte prosím hodnotu menší než {max}",
+ "error.validation.match": "Hodnota neodpovídá očekávanému vzoru",
+ "error.validation.max": "Zadejte prosím hodnotu rovnou, nebo menší než {max}",
+ "error.validation.maxlength":
+ "Zadaná hodnota je příliš dlouhá. (Povoleno nejvýše {max} znaků)",
+ "error.validation.maxwords": "Nezadávejte prosím více jak {max} slov",
+ "error.validation.min": "Zadejte prosím hodnotu rovnou, nebo větší než {min}",
+ "error.validation.minlength":
+ "Zadaná hodnota je příliš krátká. (Požadováno nejméně {min} znaků)",
+ "error.validation.minwords": "Zadejte prosím alespoň {min} slov",
+ "error.validation.more": "Zadejte prosím hodnotu větší než {min}",
+ "error.validation.notcontains":
+ "Zadejte prosím hodnotu, která neobsahuje \"{needle}\"",
+ "error.validation.notin":
+ "Nezadávejte prosím žádnou z následujíích hodnot: ({notIn})",
+ "error.validation.option": "Vyberte prosím platnou možnost",
+ "error.validation.num": "Zadejte prosím platné číslo",
+ "error.validation.required": "Zadejte prosím jakoukoli hodnotu",
+ "error.validation.same": "Zadejte prosím \"{other}\"",
+ "error.validation.size": "Velikost hodnoty musí být \"{size}\"",
+ "error.validation.startswith": "Hodnota musí začínat \"{start}\"",
+ "error.validation.time": "Zadejte prosím platný čas",
+ "error.validation.url": "Zadejte prosím platnou adresu URL",
+
+ "field.required": "Pole musí být vyplněno.",
+ "field.files.empty": "Nebyly zatím vybrány žádné soubory",
+ "field.pages.empty": "Nebyly zatím vybrány žádné stránky",
+ "field.structure.delete.confirm": "Opravdu chcete smazat tento z\u00e1znam?",
+ "field.structure.empty": "Zat\u00edm nejsou \u017e\u00e1dn\u00e9 z\u00e1znamy.",
+ "field.users.empty": "Nebyli zatím vybráni žádní uživatelé",
+
+ "file.delete.confirm":
+ "Opravdu chcete smazat tento soubor?",
+
+ "files": "Soubory",
+ "files.empty": "Zatím žádné soubory",
+
+ "hour": "Hodina",
+ "insert": "Vlo\u017eit",
+ "install": "Instalovat",
+
+ "installation": "Instalace",
+ "installation.completed": "Panel byl nainstalován",
+ "installation.disabled": "Instalátor panelu je ve výchozím nastavení na veřejných serverech zakázán. Spusťte prosím instalátor na lokálním počítači nebo jej povolte prostřednictvím panel.install
.",
+ "installation.issues.accounts":
+ "\/site\/accounts nen\u00ed zapisovateln\u00e9",
+ "installation.issues.content":
+ "Slo\u017eka content a v\u0161echny soubory a slo\u017eky v n\u00ed mus\u00ed b\u00fdt zapisovateln\u00e9.",
+ "installation.issues.curl": "Je vyžadováno rozšířeníCURL
",
+ "installation.issues.headline": "Panel nelze nainstalovat",
+ "installation.issues.mbstring":
+ "Je vyžadováno rozšířeníMB String
",
+ "installation.issues.media":
+ "Složka/media
neexistuje, nebo nemá povolený zápis",
+ "installation.issues.php": "Ujistěte se, že používátePHP 7+
",
+ "installation.issues.server":
+ "Kirby vyžadujeApache
, Nginx
neboCaddy
",
+ "installation.issues.sessions": "Složka/site/sessions
neexistuje, nebo nemá povolený zápis",
+
+ "language": "Jazyk",
+ "language.code": "Kód",
+ "language.convert": "Nastavte výchozí možnost",
+ "language.convert.confirm":
+ "
Všechny podstránky budou vymazány.",
+ "page.delete.confirm.title": "Pro potvrzení zadejte titulek stránky",
+ "page.draft.create": "Vytvořit koncept",
+ "page.duplicate.appendix": "Kopírovat",
+ "page.duplicate.files": "Kopírovat soubory",
+ "page.duplicate.pages": "Kopírovat stránky",
+ "page.status": "Stav",
+ "page.status.draft": "Koncept",
+ "page.status.draft.description":
+ "Stránka je ve stavu konceptu a je viditelná pouze pro přihlášené editory",
+ "page.status.listed": "Veřejná",
+ "page.status.listed.description": "Stránka je zveřejněná pro všechny",
+ "page.status.unlisted": "Neveřejná",
+ "page.status.unlisted.description": "Tato stránka je dostupná pouze přes URL.",
+
+ "pages": "Stránky",
+ "pages.empty": "Zatím žádné stránky",
+ "pages.status.draft": "Koncepty",
+ "pages.status.listed": "Zveřejněno",
+ "pages.status.unlisted": "Neveřejná",
+
+ "pagination.page": "Stránka",
+
+ "password": "Heslo",
+ "pixel": "Pixel",
+ "prev": "Předchozí",
+ "remove": "Odstranit",
+ "rename": "Přejmenovat",
+ "replace": "Nahradit",
+ "retry": "Zkusit znovu",
+ "revert": "Zahodit",
+
+ "role": "Role",
+ "role.admin.description": "Administrátor má všechna práva",
+ "role.admin.title": "Administrátor",
+ "role.all": "Vše",
+ "role.empty": "Neexistují uživatelé s touto rolí",
+ "role.description.placeholder": "Žádný popis",
+ "role.nobody.description": "Toto je výchozí role bez jakýchkoli oprávnění",
+ "role.nobody.title": "Nikdo",
+
+ "save": "Ulo\u017eit",
+ "search": "Hledat",
+
+ "section.required": "Sekce musí být vyplněna",
+
+ "select": "Vybrat",
+ "settings": "Nastavení",
+ "size": "Velikost",
+ "slug": "P\u0159\u00edpona URL",
+ "sort": "Řadit",
+ "title": "Název",
+ "template": "\u0160ablona",
+ "today": "Dnes",
+
+ "toolbar.button.code": "Kód",
+ "toolbar.button.bold": "Tu\u010dn\u00fd text",
+ "toolbar.button.email": "Email",
+ "toolbar.button.headings": "Nadpisy",
+ "toolbar.button.heading.1": "Nadpis 1",
+ "toolbar.button.heading.2": "Nadpis 2",
+ "toolbar.button.heading.3": "Nadpis 3",
+ "toolbar.button.italic": "Kurz\u00edva",
+ "toolbar.button.file": "Soubor",
+ "toolbar.button.file.select": "Vyberte soubor",
+ "toolbar.button.file.upload": "Nahrajte soubor",
+ "toolbar.button.link": "Odkaz",
+ "toolbar.button.ol": "Řazený seznam",
+ "toolbar.button.ul": "Odrážkový seznam",
+
+ "translation.author": "Kirby tým",
+ "translation.direction": "ltr",
+ "translation.name": "\u010cesky",
+ "translation.locale": "cs_CZ",
+
+ "upload": "Nahrát",
+ "upload.error.cantMove": "Nahraný soubor nemohl být přesunut",
+ "upload.error.cantWrite": "Zápis souboru na disk se nezdařil",
+ "upload.error.default": "Soubor se nepodařilo nahrát",
+ "upload.error.extension": "Nahrávání souboru přerušeno rozšířením.",
+ "upload.error.formSize": "Velikost nahrávaného souboru převyšuje omezení stanovené direktivou MAX_FILE_SIZE",
+ "upload.error.iniPostSize": "Velikost nahrávaného souboru převyšuje omezení stanovené direktivou post_max_size, která je nastavena v php.ini",
+ "upload.error.iniSize": "Velikost nahrávaného souboru převyšuje omezení stanovené direktivou upload_max_filesize, která je nastavena v php.ini ",
+ "upload.error.noFile": "Nebyl nahrán žádný soubor",
+ "upload.error.noFiles": "Nebyly nahrány žádné soubory",
+ "upload.error.partial": "Soubor byl nahrán pouze z části",
+ "upload.error.tmpDir": "Chybí dočasná složka",
+ "upload.errors": "Chyba",
+ "upload.progress": "Nahrávání...",
+
+ "url": "Url",
+ "url.placeholder": "https://example.com",
+
+ "user": "Uživatel",
+ "user.blueprint":
+ "Pro tuto uživatelskou roli můžete definovat další sekce a pole v /site/blueprints/users/{role}.yml",
+ "user.changeEmail": "Změnit email",
+ "user.changeLanguage": "Změnit jazyk",
+ "user.changeName": "Přejmenovat tohoto uživatele",
+ "user.changePassword": "Změnit heslo",
+ "user.changePassword.new": "Nové heslo",
+ "user.changePassword.new.confirm": "Potvrdit nové heslo...",
+ "user.changeRole": "Změnit roli",
+ "user.changeRole.select": "Vybrat novou roli",
+ "user.create": "Přidat nového uživatele",
+ "user.delete": "Smazat tohoto uživatele",
+ "user.delete.confirm":
+ "Opravdu chcete smazat tohoto u\u017eivatele?",
+
+ "users": "Uživatelé",
+
+ "version": "Verze Kirby",
+
+ "view.account": "V\u00e1\u0161 \u00fa\u010det",
+ "view.installation": "Instalace",
+ "view.settings": "Nastavení",
+ "view.site": "Stránka",
+ "view.users": "U\u017eivatel\u00e9",
+
+ "welcome": "Vítejte",
+ "year": "Rok"
+}
diff --git a/kirby/i18n/translations/da.json b/kirby/i18n/translations/da.json
new file mode 100755
index 0000000..719cc6d
--- /dev/null
+++ b/kirby/i18n/translations/da.json
@@ -0,0 +1,481 @@
+{
+ "add": "Ny",
+ "avatar": "Profilbillede",
+ "back": "Tilbage",
+ "cancel": "Annuller",
+ "change": "\u00c6ndre",
+ "close": "Luk",
+ "confirm": "Gem",
+ "copy": "Kopier",
+ "create": "Opret",
+
+ "date": "Dato",
+ "date.select": "Vælg en dato",
+
+ "day": "Dag",
+ "days.fri": "Fre",
+ "days.mon": "Man",
+ "days.sat": "L\u00f8r",
+ "days.sun": "S\u00f8n",
+ "days.thu": "Tor",
+ "days.tue": "Tir",
+ "days.wed": "Ons",
+
+ "delete": "Slet",
+ "dimensions": "Dimensioner",
+ "disabled": "Deaktiveret",
+ "discard": "Kass\u00e9r",
+ "download": "Download",
+ "duplicate": "Dupliker",
+ "edit": "Rediger",
+
+ "dialog.files.empty": "Ingen filer kan vælges",
+ "dialog.pages.empty": "Ingen sider kan vælges",
+ "dialog.users.empty": "Ingen brugere kan vælges",
+
+ "email": "Email",
+ "email.placeholder": "mail@eksempel.dk",
+
+ "error.access.login": "Ugyldigt log ind",
+ "error.access.panel": "Du har ikke adgang til panelet",
+ "error.access.view": "Du har ikke adgang til denne del af panelet",
+
+ "error.avatar.create.fail": "Profilbilledet kunne blev ikke uploadet ",
+ "error.avatar.delete.fail": "Profilbilledet kunne ikke slettes",
+ "error.avatar.dimensions.invalid":
+ "Hold venligst bredte og højde på billedet under 3000 pixels",
+ "error.avatar.mime.forbidden":
+ "Uacceptabel fil-type",
+
+ "error.blueprint.notFound": "Blueprint \"{name}\" kunne ikke indlæses",
+
+ "error.email.preset.notFound": "Email preset \"{name}\" findes ikke",
+
+ "error.field.converter.invalid": "Ugyldig converter \"{converter}\"",
+
+ "error.file.changeName.empty": "Navn kan ikke efterlades tomt",
+ "error.file.changeName.permission":
+ "Du har ikke tilladelse til at ændre navnet på filen \"{filename}\"",
+ "error.file.duplicate": "En fil med navnet \"{filename}\" eksisterer allerede",
+ "error.file.extension.forbidden":
+ "Uacceptabel fil-endelse",
+ "error.file.extension.missing":
+ "Du kan ikke uploade filer uden fil-endelse",
+ "error.file.maxheight": "Højden på billedet af billedet må ikke være større end {height} pixels",
+ "error.file.maxsize": "Filen er for stor",
+ "error.file.maxwidth": "Bredden af billedet må ikke være større end {width} pixels",
+ "error.file.mime.differs":
+ "Den uploadede fil skal være af samme mime type \"{mime}\"",
+ "error.file.mime.forbidden": "Media typen \"{mime}\" er ikke tilladt",
+ "error.file.mime.invalid": "Ugyldig mime type: {mime}",
+ "error.file.mime.missing":
+ "Media typen for \"{filename}\" kan ikke bestemmes",
+ "error.file.minheight": "Højden af billedet skal mindst være {height} pixels",
+ "error.file.minsize": "Filen er for lille",
+ "error.file.minwidth": "Bredden af billedet skal mindst være {width} pixels",
+ "error.file.name.missing": "Filnavn må ikke være tomt",
+ "error.file.notFound": "Filen kunne ikke findes",
+ "error.file.orientation": "Formatet på billedet skal være \"{orientation}\"",
+ "error.file.type.forbidden": "Du har ikke tilladelse til at uploade {type} filer",
+ "error.file.undefined": "Filen kunne ikke findes",
+
+ "error.form.incomplete": "Ret venligst alle fejl i formularen...",
+ "error.form.notSaved": "Formularen kunne ikke gemmes",
+
+ "error.language.code": "Indtast venligst en gyldig kode for sproget",
+ "error.language.duplicate": "Sproget eksisterer allerede",
+ "error.language.name": "Indtast venligst et gyldigt navn for sproget",
+
+ "error.license.format": "Indtast venligst en gyldig licensnøgle",
+ "error.license.email": "Indtast venligst en gyldig email adresse",
+ "error.license.verification": "Licensen kunne ikke verificeres",
+
+ "error.page.changeSlug.permission":
+ "Du kan ikke \u00e6ndre denne sides URL",
+ "error.page.changeStatus.incomplete":
+ "Siden indeholder fejl og kan derfor ikke udgives",
+ "error.page.changeStatus.permission":
+ "Status for denne side kan ikke ændres",
+ "error.page.changeStatus.toDraft.invalid":
+ "Siden \"{slug}\" kan ikke konverteres om til en kladde",
+ "error.page.changeTemplate.invalid":
+ "Skabelonen for siden \"{slug}\" kan ikke ændres",
+ "error.page.changeTemplate.permission":
+ "Du har ikke tilladelse til at ændre skabelonen for \"{slug}\"",
+ "error.page.changeTitle.empty": "Titlen kan ikke være tom",
+ "error.page.changeTitle.permission":
+ "Du har ikke tilladelse til at ændre titlen for \"{slug}\"",
+ "error.page.create.permission": "Du har ikke tilladelse til at oprette \"{slug}\"",
+ "error.page.delete": "Siden \"{slug}\" kan ikke slettes",
+ "error.page.delete.confirm": "Indtast venligst sidens titel for at bekræfte",
+ "error.page.delete.hasChildren":
+ "Siden har unsersider og kan derfor ikke slettes",
+ "error.page.delete.permission": "Du har ikke tilladelse til at slette \"{slug}\"",
+ "error.page.draft.duplicate":
+ "En sidekladde med URL-endelsen \"{slug}\" eksisterer allerede",
+ "error.page.duplicate":
+ "En side med URL-endelsen \"{slug}\" eksisterer allerede",
+ "error.page.duplicate.permission": "Du har ikke mulighed for at duplikere \"{slug}\"",
+ "error.page.notFound": "Siden kunne ikke findes",
+ "error.page.num.invalid":
+ "Indtast venligst et gyldigt sorteringsnummer. Nummeret kan ikke være negativt.",
+ "error.page.slug.invalid": "Indtast venligst en gyldig URL prefix",
+ "error.page.sort.permission": "Siden \"{slug}\" kan ikke sorteres",
+ "error.page.status.invalid": "Sæt venligst en gyldig status for siden",
+ "error.page.undefined": "Siden kunne ikke findes",
+ "error.page.update.permission": "Du har ikke tilladelse til at opdatere \"{slug}\"",
+
+ "error.section.files.max.plural":
+ "Du kan ikk tilføje mere end {max} filer til \"{section}\" sektionen",
+ "error.section.files.max.singular":
+ "Du kan ikke tilføje mere end en fil til \"{section}\" sektionen",
+ "error.section.files.min.plural":
+ "Sektionen \"{section}\" kræver mindst {min} filer",
+ "error.section.files.min.singular":
+ "Sektionen \"{section}\" kræver mindst en fil",
+
+ "error.section.pages.max.plural":
+ "Du kan ikke tilføje flere end {max} sider til \"{section}\" sektionen",
+ "error.section.pages.max.singular":
+ "Du kan ikke tilføje mere end een side til \"{section}\" sektionen",
+ "error.section.pages.min.plural":
+ "Sektionen \"{section}\" kræver mindst {min} sider",
+ "error.section.pages.min.singular":
+ "Sektionen \"{section}\" kræver mindst en side",
+
+ "error.section.notLoaded": "Sektionen \"{section}\" kunne ikke indlæses",
+ "error.section.type.invalid": "Sektionstypen \"{type}\" er ikke gyldig",
+
+ "error.site.changeTitle.empty": "Titlen kan ikke være tom",
+ "error.site.changeTitle.permission":
+ "Du har ikke tilladelse til at ændre titlen på sitet",
+ "error.site.update.permission": "Du har ikke tilladelse til at opdatere sitet",
+
+ "error.template.default.notFound": "Standardskabelonen eksisterer ikke",
+
+ "error.user.changeEmail.permission":
+ "Du har ikke tilladelse til at ændre emailen for brugeren \"{name}\"",
+ "error.user.changeLanguage.permission":
+ "Du har ikke tilladelse til at ændre sproget for brugeren \"{name}\"",
+ "error.user.changeName.permission":
+ "Du har ikke tilladelse til at ændre navn på brugeren \"{name}\"",
+ "error.user.changePassword.permission":
+ "Du har ikke tilladelse til at ændre adgangskoden for brugeren \"{name}\"",
+ "error.user.changeRole.lastAdmin":
+ "Rollen for den sidste admin kan ikke ændres",
+ "error.user.changeRole.permission":
+ "Du har ikke tilladelse til at ændre rollen for brugeren \"{name}\"",
+ "error.user.changeRole.toAdmin":
+ "Du har ikke tilladelse til at tildele nogen admin rollen",
+ "error.user.create.permission": "Du har ikke tilladelse til at oprette denne bruger",
+ "error.user.delete": "Brugeren kunne ikke slettes",
+ "error.user.delete.lastAdmin": "Du kan ikke slette den sidste admin",
+ "error.user.delete.lastUser": "Den sidste bruger kan ikke slettes",
+ "error.user.delete.permission":
+ "Du har ikke tilladelse til at slette denne bruger",
+ "error.user.duplicate":
+ "En bruger med email adresse \"{email}\" eksisterer allerede",
+ "error.user.email.invalid": "Indtast venligst en gyldig email adresse",
+ "error.user.language.invalid": "Indtast venligst et gyldigt sprog",
+ "error.user.notFound": "Brugeren kunne ikke findes",
+ "error.user.password.invalid":
+ "Indtast venligst en gyldig adgangskode. Adgangskoder skal minimum være 8 tegn lange.",
+ "error.user.password.notSame": "Bekr\u00e6ft venligst adgangskoden",
+ "error.user.password.undefined": "Brugeren har ikke en adgangskode",
+ "error.user.role.invalid": "Indtast venligst en gyldig rolle",
+ "error.user.update.permission":
+ "Du har ikke tilladelse til at opdatere brugeren \"{name}\"",
+
+ "error.validation.accepted": "Bekræft venligst",
+ "error.validation.alpha": "Indtast venligst kun bogstaver imellem a-z",
+ "error.validation.alphanum":
+ "Indtast venligst kun bogstaver og tal imellem a-z eller 0-9",
+ "error.validation.between":
+ "Indtast venligst en værdi imellem \"{min}\" og \"{max}\"",
+ "error.validation.boolean": "Venligst bekræft eller afvis",
+ "error.validation.contains":
+ "Indtast venligst en værdi der indeholder \"{needle}\"",
+ "error.validation.date": "Indtast venligst en gyldig dato",
+ "error.validation.date.after": "Indtast venligst en dato efter {date}",
+ "error.validation.date.before": "Indtast venligst en dato før {date}",
+ "error.validation.date.between": "Indtast venligst en dato imellem {min} og {max}",
+ "error.validation.denied": "Venligst afvis",
+ "error.validation.different": "Værdien må ikke være \"{other}\"",
+ "error.validation.email": "Indtast venligst en gyldig email adresse",
+ "error.validation.endswith": "Værdi skal ende med \"{end}\"",
+ "error.validation.filename": "Indtast venligst et gyldigt filnavn",
+ "error.validation.in": "Indtast venligst en af følgende: ({in})",
+ "error.validation.integer": "Indtast et gyldigt tal",
+ "error.validation.ip": "Indtast en gyldig IP adresse",
+ "error.validation.less": "Indtast venligst en værdi mindre end {max}",
+ "error.validation.match": "Værdien matcher ikke det forventede mønster",
+ "error.validation.max": "Indtast venligst en værdi lig med eller lavere end {max}",
+ "error.validation.maxlength":
+ "Indtast venligst en kortere værdi. (maks. {max} karakterer)",
+ "error.validation.maxwords": "Indtast ikke flere end {max} ord",
+ "error.validation.min": "Indtast en værdi lig med eller højere end {min}",
+ "error.validation.minlength":
+ "Indtast venligst en længere værdi. (min. {min} karakterer)",
+ "error.validation.minwords": "Indtast venligst mindst {min} ord",
+ "error.validation.more": "Indtast venligst en værdi større end {min}",
+ "error.validation.notcontains":
+ "Indtast venligst en værdi der ikke indeholder \"{needle}\"",
+ "error.validation.notin":
+ "Indtast venligst ikke nogen af følgende: ({notIn})",
+ "error.validation.option": "Vælg venligst en gyldig mulighed",
+ "error.validation.num": "Indtast venligst et gyldigt nummer",
+ "error.validation.required": "Indtast venligst noget",
+ "error.validation.same": "Indtast venligst \"{other}\"",
+ "error.validation.size": "Størrelsen på værdien skal være \"{size}\"",
+ "error.validation.startswith": "Værdien skal starte med \"{start}\"",
+ "error.validation.time": "Indtast venligst et gyldigt tidspunkt",
+ "error.validation.url": "Indtast venligst en gyldig URL",
+
+ "field.required": "Feltet er påkrævet",
+ "field.files.empty": "Ingen filer valgt endnu",
+ "field.pages.empty": "Ingen sider valgt endnu",
+ "field.structure.delete.confirm": "\u00d8nsker du virkelig at slette denne indtastning?",
+ "field.structure.empty": "Ingen indtastninger endnu.",
+ "field.users.empty": "Ingen brugere er valgt",
+
+ "file.delete.confirm":
+ "\u00d8nsker du virkelig at slette denne fil?",
+
+ "files": "Filer",
+ "files.empty": "Ingen filer endnu",
+
+ "hour": "Time",
+ "insert": "Inds\u00e6t",
+ "install": "Installer",
+
+ "installation": "Installation",
+ "installation.completed": "Panelet er blevet installeret",
+ "installation.disabled": "Panel installationen er deaktiveret på offentlige servere som standard. Kør venligst installationen på en lokal maskine eller aktiver det med panel.install panel.install
muligheden.",
+ "installation.issues.accounts":
+ "\/site\/accounts er ikke skrivbar",
+ "installation.issues.content":
+ "Content mappen samt alle underliggende filer og mapper skal v\u00e6re skrivbare.",
+ "installation.issues.curl": "CURL
extension er påkrævet",
+ "installation.issues.headline": "Panelet kan ikke installeres",
+ "installation.issues.mbstring":
+ "MB String
extension er påkrævet",
+ "installation.issues.media":
+ "/media
mappen eksisterer ikke eller er ikke skrivbar",
+ "installation.issues.php": "Sikre dig at der benyttes PHP 7+
",
+ "installation.issues.server":
+ "Kirby kræver Apache
, Nginx
eller Caddy
",
+ "installation.issues.sessions": "/site/sessions mappen eksisterer ikke eller er ikke skrivbar",
+
+ "language": "Sprog",
+ "language.code": "Kode",
+ "language.convert": "Gør standard",
+ "language.convert.confirm":
+ "
Alle undersider vil også blive slettet.",
+ "page.delete.confirm.title": "Indtast sidens titel for at bekræfte",
+ "page.draft.create": "Opret kladde",
+ "page.duplicate.appendix": "Kopier",
+ "page.duplicate.files": "Kopier filer",
+ "page.duplicate.pages": "Kopier sider",
+ "page.status": "Status",
+ "page.status.draft": "Kladde",
+ "page.status.draft.description":
+ "Siden er i kladdetilstand og kun synlig for redaktører der er logget ind",
+ "page.status.listed": "Offentlig",
+ "page.status.listed.description": "Siden er offentlig for enhver",
+ "page.status.unlisted": "Ulistede",
+ "page.status.unlisted.description": "Siden er kun tilgængelig via URL",
+
+ "pages": "Sider",
+ "pages.empty": "Ingen sider endnu",
+ "pages.status.draft": "Kladder",
+ "pages.status.listed": "Udgivede",
+ "pages.status.unlisted": "Ulistede",
+
+ "pagination.page": "Side",
+
+ "password": "Adgangskode",
+ "pixel": "Pixel",
+ "prev": "Forrige",
+ "remove": "Fjern",
+ "rename": "Omdøb",
+ "replace": "Erstat",
+ "retry": "Pr\u00f8v igen",
+ "revert": "Kass\u00e9r",
+
+ "role": "Rolle",
+ "role.admin.description": "Admin har alle rettigheder",
+ "role.admin.title": "Admin",
+ "role.all": "All",
+ "role.empty": "Der er ingen bruger med denne rolle",
+ "role.description.placeholder": "Ingen beskrivelse",
+ "role.nobody.description": "Dette er en tilbagefaldsrolle uden rettigheder",
+ "role.nobody.title": "Ingen",
+
+ "save": "Gem",
+ "search": "Søg",
+
+ "section.required": "Sektionen er påkrævet",
+
+ "select": "Vælg",
+ "settings": "Indstillinger",
+ "size": "Størrelse",
+ "slug": "URL-appendiks",
+ "sort": "Sorter",
+ "title": "Titel",
+ "template": "Skabelon",
+ "today": "Idag",
+
+ "toolbar.button.code": "Kode",
+ "toolbar.button.bold": "Fed tekst",
+ "toolbar.button.email": "Email",
+ "toolbar.button.headings": "Overskrifter",
+ "toolbar.button.heading.1": "Overskrift 1",
+ "toolbar.button.heading.2": "Overskrift 2",
+ "toolbar.button.heading.3": "Overskrift 3",
+ "toolbar.button.italic": "Kursiv tekst",
+ "toolbar.button.file": "Fil",
+ "toolbar.button.file.select": "Vælg en fil",
+ "toolbar.button.file.upload": "Upload en fil",
+ "toolbar.button.link": "Link",
+ "toolbar.button.ol": "Ordnet liste",
+ "toolbar.button.ul": "Punktliste",
+
+ "translation.author": "Kirby Team",
+ "translation.direction": "ltr",
+ "translation.name": "Dansk",
+ "translation.locale": "da_DK",
+
+ "upload": "Upload",
+ "upload.error.cantMove": "Den uploadede fil kunne ikke flyttes",
+ "upload.error.cantWrite": "Kunne ikke skrive fil til disk",
+ "upload.error.default": "Filen kunne ikke uploades",
+ "upload.error.extension": "Upload af filen blev stoppet af dens type",
+ "upload.error.formSize": "Filen overskrider MAX_FILE_SIZE direktivet der er specificeret for formularen",
+ "upload.error.iniPostSize": "FIlen overskrider post_max_size direktivet i php.ini",
+ "upload.error.iniSize": "FIlen overskrider upload_max_filesize direktivet i php.ini",
+ "upload.error.noFile": "Ingen fil blev uploadet",
+ "upload.error.noFiles": "Ingen filer blev uploadet",
+ "upload.error.partial": "Den uploadede fil blev kun delvist uploadet",
+ "upload.error.tmpDir": "Der mangler en midlertidig mappe",
+ "upload.errors": "Fejl",
+ "upload.progress": "Uploader...",
+
+ "url": "Url",
+ "url.placeholder": "https://example.com",
+
+ "user": "Bruger",
+ "user.blueprint":
+ "Du kan definere yderligere sektioner og formular felter for denne brugerrolle i /site/blueprints/users/{role}.yml",
+ "user.changeEmail": "Skift email",
+ "user.changeLanguage": "Skift sprog",
+ "user.changeName": "Omdøb denne bruger",
+ "user.changePassword": "Skift adgangskode",
+ "user.changePassword.new": "Ny adgangskode",
+ "user.changePassword.new.confirm": "Bekræft den nye adgangskode...",
+ "user.changeRole": "Skift rolle",
+ "user.changeRole.select": "Vælg en ny rolle",
+ "user.create": "Tilføj en ny bruger",
+ "user.delete": "Slet denne bruger",
+ "user.delete.confirm":
+ "\u00d8nsker du virkelig at slette denne bruger?",
+
+ "users": "Brugere",
+
+ "version": "Kirby version",
+
+ "view.account": "Din konto",
+ "view.installation": "Installation",
+ "view.settings": "Indstillinger",
+ "view.site": "Website",
+ "view.users": "Brugere",
+
+ "welcome": "Velkommen",
+ "year": "År"
+}
diff --git a/kirby/i18n/translations/de.json b/kirby/i18n/translations/de.json
new file mode 100755
index 0000000..a2e3b3c
--- /dev/null
+++ b/kirby/i18n/translations/de.json
@@ -0,0 +1,481 @@
+{
+ "add": "Hinzuf\u00fcgen",
+ "avatar": "Profilbild",
+ "back": "Zurück",
+ "cancel": "Abbrechen",
+ "change": "\u00c4ndern",
+ "close": "Schlie\u00dfen",
+ "confirm": "OK",
+ "copy": "Kopieren",
+ "create": "Erstellen",
+
+ "date": "Datum",
+ "date.select": "Datum auswählen",
+
+ "day": "Tag",
+ "days.fri": "Fr",
+ "days.mon": "Mo",
+ "days.sat": "Sa",
+ "days.sun": "So",
+ "days.thu": "Do",
+ "days.tue": "Di",
+ "days.wed": "Mi",
+
+ "delete": "L\u00f6schen",
+ "dimensions": "Maße",
+ "disabled": "Gesperrt",
+ "discard": "Verwerfen",
+ "download": "Download",
+ "duplicate": "Duplizieren",
+ "edit": "Bearbeiten",
+
+ "dialog.files.empty": "Keine verfügbaren Dateien",
+ "dialog.pages.empty": "Keine verfügbaren Seiten",
+ "dialog.users.empty": "Keine verfügbaren Benutzer",
+
+ "email": "E-Mail",
+ "email.placeholder": "mail@beispiel.de",
+
+ "error.access.login": "Ungültige Zugangsdaten",
+ "error.access.panel": "Du hast keinen Zugang zum Panel",
+ "error.access.view": "Du hast keinen Zugriff auf diesen Teil des Panels",
+
+ "error.avatar.create.fail": "Das Profilbild konnte nicht hochgeladen werden",
+ "error.avatar.delete.fail": "Das Profilbild konnte nicht gel\u00f6scht werden",
+ "error.avatar.dimensions.invalid":
+ "Bitte lade ein Profilbild hoch, das nicht breiter oder höher als 3000 Pixel ist.",
+ "error.avatar.mime.forbidden":
+ "Das Profilbild muss vom Format JPEG oder PNG sein",
+
+ "error.blueprint.notFound": "Das Blueprint \"{name}\" konnte nicht geladen werden.",
+
+ "error.email.preset.notFound": "Die E-Mailvorlage \"{name}\" wurde nicht gefunden",
+
+ "error.field.converter.invalid": "Ungültiger Konverter: \"{converter}\"",
+
+ "error.file.changeName.empty": "Bitte gib einen Namen an",
+ "error.file.changeName.permission":
+ "Du darfst den Dateinamen von \"{filename}\" nicht ändern",
+ "error.file.duplicate": "Eine Datei mit dem Dateinamen \"{filename}\" besteht bereits",
+ "error.file.extension.forbidden":
+ "Verbotene Dateiendung \"{extension}\"",
+ "error.file.extension.missing":
+ "Du kannst keine Dateien ohne Dateiendung hochladen",
+ "error.file.maxheight": "Die Bildhöhe darf {height} Pixel nicht überschreiten",
+ "error.file.maxsize": "Die Datei ist zu groß",
+ "error.file.maxwidth": "Die Bildbreite darf {height} Pixel nicht überschreiten",
+ "error.file.mime.differs":
+ "Die Datei muss den Medientyp \"{mime}\" haben.",
+ "error.file.mime.forbidden": "Der Medientyp \"{mime}\" ist nicht erlaubt",
+ "error.file.mime.invalid": "Ungültiger Dateityp: {mime}",
+ "error.file.mime.missing":
+ "Der Medientyp für \"{filename}\" konnte nicht erkannt werden",
+ "error.file.minheight": "Die Bildhöhe muss mindestens {height} Pixel betragen",
+ "error.file.minsize": "Die Datei ist zu klein",
+ "error.file.minwidth": "Die Bildbreite muss mindestens {height} Pixel betragen",
+ "error.file.name.missing": "Bitte gib einen Dateinamen an",
+ "error.file.notFound": "Die Datei \"{filename}\" konnte nicht gefunden werden",
+ "error.file.orientation": "Das Bildformat ist ungültig. Erwartetes Format: \"{orientation}\"",
+ "error.file.type.forbidden": "Du kannst keinen {type}-Dateien hochladen",
+ "error.file.undefined": "Die Datei konnte nicht gefunden werden",
+
+ "error.form.incomplete": "Bitte behebe alle Fehler …",
+ "error.form.notSaved": "Das Formular konnte nicht gespeichert werden",
+
+ "error.language.code": "Bitte gib einen gültigen Code für die Sprache an",
+ "error.language.duplicate": "Die Sprache besteht bereits",
+ "error.language.name": "Bitte gib einen gültigen Namen für die Sprache an",
+
+ "error.license.format": "Bitte gib einen gültigen Lizenzschlüssel ein",
+ "error.license.email": "Bitte gib eine gültige E-Mailadresse an",
+ "error.license.verification": "Die Lizenz konnte nicht verifiziert werden",
+
+ "error.page.changeSlug.permission":
+ "Du darfst die URL der Seite \"{slug}\" nicht ändern",
+ "error.page.changeStatus.incomplete":
+ "Die Seite ist nicht vollständig und kann daher nicht veröffentlicht werden",
+ "error.page.changeStatus.permission":
+ "Der Status der Seite kann nicht geändert werden",
+ "error.page.changeStatus.toDraft.invalid":
+ "Die Seite \"{slug}\" kann nicht in einen Entwurf umgewandelt werden",
+ "error.page.changeTemplate.invalid":
+ "Die Vorlage für die Seite \"{slug}\" kann nicht geändert werden",
+ "error.page.changeTemplate.permission":
+ "Du kannst die Vorlage für die Seite \"{slug}\" nicht ändern",
+ "error.page.changeTitle.empty": "Bitte gib einen Titel an",
+ "error.page.changeTitle.permission":
+ "Du kannst den Titel für die Seite \"{slug}\" nicht ändern",
+ "error.page.create.permission": "Du kannst die Seite \"{slug}\" nicht anlegen",
+ "error.page.delete": "Die Seite \"{slug}\" kann nicht gelöscht werden",
+ "error.page.delete.confirm": "Bitte gib zur Bestätigung den Seitentitel ein",
+ "error.page.delete.hasChildren":
+ "Die Seite hat Unterseiten und kann nicht gelöscht werden",
+ "error.page.delete.permission": "Du kannst die Seite \"{slug}\" nicht löschen",
+ "error.page.draft.duplicate":
+ "Ein Entwurf mit dem URL-Kürzel \"{slug}\" besteht bereits",
+ "error.page.duplicate":
+ "Eine Seite mit dem URL-Kürzel \"{slug}\" besteht bereits",
+ "error.page.duplicate.permission": "Du kannst die Seite \"{slug}\" nicht duplizieren",
+ "error.page.notFound": "Die Seite \"{slug}\" konnte nicht gefunden werden",
+ "error.page.num.invalid":
+ "Bitte gib eine gültige Sortierungszahl an. Negative Zahlen sind nicht erlaubt.",
+ "error.page.slug.invalid": "Bitte gib ein gültiges URL-Kürzel an",
+ "error.page.sort.permission": "Die Seite \"{slug}\" kann nicht umsortiert werden",
+ "error.page.status.invalid": "Bitte gib einen gültigen Seitenstatus an",
+ "error.page.undefined": "Die Seite konnte nicht gefunden werden",
+ "error.page.update.permission": "Du kannst die Seite \"{slug}\" nicht editieren",
+
+ "error.section.files.max.plural":
+ "Bitte füge nicht mehr als {max} Dateien zum Bereich \"{section}\" hinzu",
+ "error.section.files.max.singular":
+ "Bitte füge nicht mehr als eine Datei zum Bereich \"{section}\" hinzu",
+ "error.section.files.min.plural":
+ "Der Bereich \"{section}\" benötigt mindestens {min} Dateien",
+ "error.section.files.min.singular":
+ "Der Bereich \"{section}\" benötigt mindestens eine Datei",
+
+ "error.section.pages.max.plural":
+ "Bitte füge nicht mehr als {max} Seiten zum Bereich \"{section}\" hinzu",
+ "error.section.pages.max.singular":
+ "Bitte füge nicht mehr als eine Seite zum Bereich \"{section}\" hinzu",
+ "error.section.pages.min.plural":
+ "Der Bereich \"{section}\" benötigt mindestens {min} Seiten",
+ "error.section.pages.min.singular":
+ "Der Bereich \"{section}\" benötigt mindestens eine Seite",
+
+ "error.section.notLoaded": "Der Bereich \"{name}\" konnte nicht geladen werden",
+ "error.section.type.invalid": "Der Bereichstyp \"{type}\" ist nicht gültig",
+
+ "error.site.changeTitle.empty": "Bitte gib einen Titel an",
+ "error.site.changeTitle.permission":
+ "Du kannst den Titel der Seite nicht ändern",
+ "error.site.update.permission": "Du darfst die Seite nicht editieren",
+
+ "error.template.default.notFound": "Die \"Default\"-Vorlage existiert nicht",
+
+ "error.user.changeEmail.permission":
+ "Du kannst die E-Mailadresse für den Benutzer \"{name}\" nicht ändern",
+ "error.user.changeLanguage.permission":
+ "Du kannst die Sprache für den Benutzer \"{name}\" nicht ändern",
+ "error.user.changeName.permission":
+ "Du kannst den Namen für den Benutzer \"{name}\" nicht ändern",
+ "error.user.changePassword.permission":
+ "Du kannst das Passwort für den Benutzer \"{name}\" nicht ändern",
+ "error.user.changeRole.lastAdmin":
+ "Die Rolle des letzten Administrators kann nicht geändert werden",
+ "error.user.changeRole.permission":
+ "Du kannst die Rolle für den Benutzer \"{name}\" nicht ändern",
+ "error.user.changeRole.toAdmin":
+ "Du darfst die Admin Rolle nicht an andere Benutzer vergeben",
+ "error.user.create.permission": "Du kannst diesen Benutzer nicht anlegen",
+ "error.user.delete": "Der Benutzer \"{name}\" konnte nicht gelöscht werden",
+ "error.user.delete.lastAdmin": "Du kannst den letzten Admin nicht l\u00f6schen",
+ "error.user.delete.lastUser": "Der letzte Benutzer kann nicht gelöscht werden",
+ "error.user.delete.permission":
+ "Du darfst den Benutzer \"{name}\" nicht löschen",
+ "error.user.duplicate":
+ "Ein Benutzer mit der E-Mailadresse \"{email}\" besteht bereits",
+ "error.user.email.invalid": "Bitte gib eine gültige E-Mailadresse an",
+ "error.user.language.invalid": "Bitte gib eine gültige Sprache an",
+ "error.user.notFound": "Der Benutzer \"{name}\" wurde nicht gefunden",
+ "error.user.password.invalid":
+ "Bitte gib ein gültiges Passwort ein. Passwörter müssen mindestens 8 Zeichen lang sein.",
+ "error.user.password.notSame": "Die Passwörter stimmen nicht überein",
+ "error.user.password.undefined": "Der Benutzer hat kein Passwort",
+ "error.user.role.invalid": "Bitte gib eine gültige Rolle an",
+ "error.user.update.permission":
+ "Du darfst den den Benutzer \"{name}\" nicht editieren",
+
+ "error.validation.accepted": "Bitte bestätige",
+ "error.validation.alpha": "Bitte gib nur Zeichen zwischen A und Z ein",
+ "error.validation.alphanum":
+ "Bitte gib nur Zeichen zwischen A und Z und Zahlen zwischen 0 und 9 ein",
+ "error.validation.between":
+ "Bitte gib einen Wert zwischen \"{min}\" und \"{max}\" ein",
+ "error.validation.boolean": "Bitte bestätige oder lehne ab",
+ "error.validation.contains":
+ "Bitte gib einen Wert ein, der \"{needle}\" enthält",
+ "error.validation.date": "Bitte gib ein gültiges Datum ein",
+ "error.validation.date.after": "Bitte gib ein Datum nach dem {date} ein",
+ "error.validation.date.before": "Bitte gib ein Datum vor dem {date} ein",
+ "error.validation.date.between": "Bitte gib ein Datum zwischen dem {min} und dem {max} ein",
+ "error.validation.denied": "Bitte lehne die Eingabe ab",
+ "error.validation.different": "Der Wert darf nicht \"{other}\" sein",
+ "error.validation.email": "Bitte gib eine gültige E-Mailadresse an",
+ "error.validation.endswith": "Der Wert muss auf \"{end}\" enden",
+ "error.validation.filename": "Bitte gib einen gültigen Dateinamen ein",
+ "error.validation.in": "Bitte gib einen der folgenden Werte ein: ({in})",
+ "error.validation.integer": "Bitte gib eine ganze Zahl ein",
+ "error.validation.ip": "Bitte gib eine gültige IP Adresse ein",
+ "error.validation.less": "Bitte gib einen Wert kleiner als {max} ein",
+ "error.validation.match": "Der Wert entspricht nicht dem erwarteten Muster",
+ "error.validation.max": "Bitte gib einen Wert ein, der nicht größer als {max} ist",
+ "error.validation.maxlength":
+ "Bitte gib einen kürzeren Text ein (max. {max} Zeichen)",
+ "error.validation.maxwords": "Bitte nutze nicht mehr als {max} Wort(e)",
+ "error.validation.min": "Bitte gib einen Wert ein, der nicht kleiner als {min} ist",
+ "error.validation.minlength":
+ "Bitte gib einen längeren Text ein. (min. {min} Zeichen)",
+ "error.validation.minwords": "Bitte nutze mindestens {min} Wort(e)",
+ "error.validation.more": "Bitte gib einen größeren Wert als {min} ein",
+ "error.validation.notcontains":
+ "Bitte gib einen Wert ein, der nicht \"{needle}\" enthält",
+ "error.validation.notin":
+ "Bitte gib keinen der folgenden Werte ein: ({notIn})",
+ "error.validation.option": "Bitte wähle eine gültige Option aus",
+ "error.validation.num": "Bitte gib eine gültige Zahl an",
+ "error.validation.required": "Bitte gib etwas ein",
+ "error.validation.same": "Bitte gib \"{other}\" ein",
+ "error.validation.size": "Die Größe des Wertes muss \"{size}\" sein",
+ "error.validation.startswith": "Der Wert muss mit \"{start}\" beginnen",
+ "error.validation.time": "Bitte gib eine gültige Uhrzeit ein",
+ "error.validation.url": "Bitte gib eine gültige URL ein",
+
+ "field.required": "Das Feld ist Pflicht",
+ "field.files.empty": "Keine Dateien ausgewählt",
+ "field.pages.empty": "Keine Seiten ausgewählt",
+ "field.structure.delete.confirm": "Willst du diesen Eintrag wirklich l\u00f6schen?",
+ "field.structure.empty": "Es bestehen keine Eintr\u00e4ge.",
+ "field.users.empty": "Keine Benutzer ausgewählt",
+
+ "file.delete.confirm":
+ "Willst du die Datei {filename}
wirklich löschen?",
+
+ "files": "Dateien",
+ "files.empty": "Keine Dateien",
+
+ "hour": "Stunde",
+ "insert": "Einf\u00fcgen",
+ "install": "Installieren",
+
+ "installation": "Installation",
+ "installation.completed": "Das Panel wurde installiert",
+ "installation.disabled": "Die Panel-Installation ist auf öffentlichen Servern automatisch deaktiviert. Bitte installiere das Panel auf einem lokalen Server oder aktiviere die Installation gezielt mit der panel.install
Option. ",
+ "installation.issues.accounts":
+ "/site/accounts
ist nicht beschreibbar",
+ "installation.issues.content":
+ "/content
existiert nicht oder ist nicht beschreibbar",
+ "installation.issues.curl": "Die CURL
Erweiterung wird benötigt",
+ "installation.issues.headline": "Das Panel kann nicht installiert werden",
+ "installation.issues.mbstring":
+ "Die MB String
Erweiterung wird benötigt",
+ "installation.issues.media":
+ "Der /media
Ordner ist nicht beschreibbar",
+ "installation.issues.php": "Bitte verwende PHP 7+
",
+ "installation.issues.server":
+ "Kirby benötigt Apache
, Nginx
or Caddy
",
+ "installation.issues.sessions": "/site/sessions
ist nicht beschreibbar",
+
+ "language": "Sprache",
+ "language.code": "Code",
+ "language.convert": "Als Standard auswählen",
+ "language.convert.confirm":
+ "
Alle Unterseiten werden ebenfalls gelöscht.",
+ "page.delete.confirm.title": "Gib zur Bestätigung den Seitentitel ein",
+ "page.draft.create": "Entwurf anlegen",
+ "page.duplicate.appendix": "Kopie",
+ "page.duplicate.files": "Dateien kopieren",
+ "page.duplicate.pages": "Seiten kopieren",
+ "page.status": "Status",
+ "page.status.draft": "Entwurf",
+ "page.status.draft.description":
+ "Die Seite ist im Entwurfsmodus und ist nur für angemeldete Benutzer sichtbar",
+ "page.status.listed": "Öffentlich",
+ "page.status.listed.description": "Die Seite ist öffentlich für alle Besucher",
+ "page.status.unlisted": "Ungelistet",
+ "page.status.unlisted.description": "Die Seite kann nur über die URL aufgerufen werden",
+
+ "pages": "Seiten",
+ "pages.empty": "Keine Seiten",
+ "pages.status.draft": "Entwürfe",
+ "pages.status.listed": "Veröffentlicht",
+ "pages.status.unlisted": "Ungelistet",
+
+ "pagination.page": "Seite",
+
+ "password": "Passwort",
+ "pixel": "Pixel",
+ "prev": "Vorheriger Eintrag",
+ "remove": "Entfernen",
+ "rename": "Umbenennen",
+ "replace": "Ersetzen",
+ "retry": "Wiederholen",
+ "revert": "Verwerfen",
+
+ "role": "Rolle",
+ "role.admin.description": "Administratoren haben alle Rechte",
+ "role.admin.title": "Administrator",
+ "role.all": "Alle",
+ "role.empty": "Keine Benutzer mit dieser Rolle",
+ "role.description.placeholder": "Keine Beschreibung",
+ "role.nobody.description": "Dies ist die Platzhalterrolle ohne Rechte",
+ "role.nobody.title": "Niemand",
+
+ "save": "Speichern",
+ "search": "Suchen",
+
+ "section.required": "Der Bereich ist Pflicht",
+
+ "select": "Auswählen",
+ "settings": "Einstellungen",
+ "size": "Größe",
+ "slug": "URL-Anhang",
+ "sort": "Sortieren",
+ "title": "Titel",
+ "template": "Vorlage",
+ "today": "Heute",
+
+ "toolbar.button.code": "Code",
+ "toolbar.button.bold": "Fetter Text",
+ "toolbar.button.email": "E-Mail",
+ "toolbar.button.headings": "Überschriften",
+ "toolbar.button.heading.1": "Überschrift 1",
+ "toolbar.button.heading.2": "Überschrift 2",
+ "toolbar.button.heading.3": "Überschrift 3",
+ "toolbar.button.italic": "Kursiver Text",
+ "toolbar.button.file": "Datei",
+ "toolbar.button.file.select": "Datei auswählen",
+ "toolbar.button.file.upload": "Datei hochladen",
+ "toolbar.button.link": "Link",
+ "toolbar.button.ol": "Geordnete Liste",
+ "toolbar.button.ul": "Ungeordnete Liste",
+
+ "translation.author": "Kirby Team",
+ "translation.direction": "ltr",
+ "translation.name": "Deutsch",
+ "translation.locale": "de_DE",
+
+ "upload": "Hochladen",
+ "upload.error.cantMove": "Die Datei konnte nicht an ihren Zielort bewegt werden",
+ "upload.error.cantWrite": "Die Datei konnte nicht auf der Festplatte gespeichert werden",
+ "upload.error.default": "Die Datei konnte nicht hochgeladen werden",
+ "upload.error.extension": "Der Dateiupload wurde durch eine Erweiterung verhindert",
+ "upload.error.formSize": "Die Datei ist größer als die MAX_FILE_SIZE Einstellung im Formular",
+ "upload.error.iniPostSize": "Die Datei ist größer als die post_max_size Einstellung in der php.ini",
+ "upload.error.iniSize": "Die Datei ist größer als die upload_max_filesize Einstellung in der php.ini",
+ "upload.error.noFile": "Es wurde keine Datei hochgeladen",
+ "upload.error.noFiles": "Es wurden keine Dateien hochgeladen",
+ "upload.error.partial": "Die Datei wurde nur teilweise hochgeladen",
+ "upload.error.tmpDir": "Der temporäre Ordner für den Dateiupload existiert leider nicht",
+ "upload.errors": "Fehler",
+ "upload.progress": "Hochladen …",
+
+ "url": "Url",
+ "url.placeholder": "https://beispiel.de",
+
+ "user": "Benutzer",
+ "user.blueprint":
+ "Du kannst zusätzliche Felder und Bereiche für diese Benutzerrolle in /site/blueprints/users/{role}.yml anlegen",
+ "user.changeEmail": "E-Mail ändern",
+ "user.changeLanguage": "Sprache ändern",
+ "user.changeName": "Benutzer umbenennen",
+ "user.changePassword": "Passwort ändern",
+ "user.changePassword.new": "Neues Passwort",
+ "user.changePassword.new.confirm": "Wiederhole das Passwort …",
+ "user.changeRole": "Rolle ändern",
+ "user.changeRole.select": "Neue Rolle auswählen",
+ "user.create": "Neuen Benutzer anlegen",
+ "user.delete": "Benutzer löschen",
+ "user.delete.confirm":
+ "Willst du den Benutzer
{email} wirklich löschen?",
+
+ "users": "Benutzer",
+
+ "version": "Version",
+
+ "view.account": "Dein Account",
+ "view.installation": "Installation",
+ "view.settings": "Einstellungen",
+ "view.site": "Seite",
+ "view.users": "Benutzer",
+
+ "welcome": "Willkommen",
+ "year": "Jahr"
+}
diff --git a/kirby/i18n/translations/el.json b/kirby/i18n/translations/el.json
new file mode 100755
index 0000000..d7b08eb
--- /dev/null
+++ b/kirby/i18n/translations/el.json
@@ -0,0 +1,481 @@
+{
+ "add": "\u03a0\u03c1\u03bf\u03c3\u03b8\u03ae\u03ba\u03b7",
+ "avatar": "\u0395\u03b9\u03ba\u03cc\u03bd\u03b1 \u03c0\u03c1\u03bf\u03c6\u03af\u03bb",
+ "back": "Πίσω",
+ "cancel": "\u0391\u03ba\u03cd\u03c1\u03c9\u03c3\u03b7",
+ "change": "\u0391\u03bb\u03bb\u03b1\u03b3\u03ae",
+ "close": "\u039a\u03bb\u03b5\u03af\u03c3\u03b9\u03bc\u03bf",
+ "confirm": "Εντάξει",
+ "copy": "Αντιγραφή",
+ "create": "Δημιουργία",
+
+ "date": "Ημερομηνία",
+ "date.select": "Επιλογή ημερομηνίας",
+
+ "day": "Ημέρα",
+ "days.fri": "\u03a0\u03b1\u03c1",
+ "days.mon": "\u0394\u03b5\u03c5",
+ "days.sat": "\u03a3\u03ac\u03b2",
+ "days.sun": "\u039a\u03c5\u03c1",
+ "days.thu": "\u03a0\u03ad\u03bc",
+ "days.tue": "\u03a4\u03c1\u03af",
+ "days.wed": "\u03a4\u03b5\u03c4",
+
+ "delete": "\u0394\u03b9\u03b1\u03b3\u03c1\u03b1\u03c6\u03ae",
+ "dimensions": "Διαστάσεις",
+ "disabled": "Disabled",
+ "discard": "Απόρριψη",
+ "download": "Download",
+ "duplicate": "Duplicate",
+ "edit": "\u0395\u03c0\u03b5\u03be\u03b5\u03c1\u03b3\u03b1\u03c3\u03af\u03b1",
+
+ "dialog.files.empty": "No files to select",
+ "dialog.pages.empty": "No pages to select",
+ "dialog.users.empty": "No users to select",
+
+ "email": "Διεύθυνση ηλεκτρονικού ταχυδρομείου",
+ "email.placeholder": "mail@example.com",
+
+ "error.access.login": "Mη έγκυρη σύνδεση",
+ "error.access.panel": "Δεν επιτρέπεται η πρόσβαση στον πίνακα ελέγχου",
+ "error.access.view": "You are not allowed to access this part of the panel",
+
+ "error.avatar.create.fail": "Δεν ήταν δυνατή η μεταφόρτωση της εικόνας προφίλ",
+ "error.avatar.delete.fail": "Δεν ήταν δυνατή η διαγραφή της εικόνας προφίλ",
+ "error.avatar.dimensions.invalid":
+ "Διατηρήστε το πλάτος και το ύψος της εικόνας προφίλ κάτω από 3000 εικονοστοιχεία",
+ "error.avatar.mime.forbidden":
+ "\u039c\u03b7 \u03b1\u03c0\u03bf\u03b4\u03b5\u03ba\u03c4\u03cc\u03c2 \u03c4\u03cd\u03c0\u03bf\u03c2 \u03b1\u03c1\u03c7\u03b5\u03af\u03bf\u03c5",
+
+ "error.blueprint.notFound": "Δεν ήταν δυνατή η φόρτωση του προσχεδίου \"{name}\"",
+
+ "error.email.preset.notFound": "Δεν είναι δυνατή η εύρεση της προεπιλογής διεύθινσης ηλεκτρονικού ταχυδρομείου \"{name}\"",
+
+ "error.field.converter.invalid": "Μη έγκυρος μετατροπέας \"{converter}\"",
+
+ "error.file.changeName.empty": "The name must not be empty",
+ "error.file.changeName.permission":
+ "Δεν επιτρέπεται να αλλάξετε το όνομα του \"{filename}\"",
+ "error.file.duplicate": "Ένα αρχείο με το όνομα \"{filename}\" υπάρχει ήδη",
+ "error.file.extension.forbidden":
+ "\u039c\u03b7 \u03b1\u03c0\u03bf\u03b4\u03b5\u03ba\u03c4\u03ae \u03b5\u03c0\u03ad\u03ba\u03c4\u03b1\u03c3\u03b7 \u03b1\u03c1\u03c7\u03b5\u03af\u03bf\u03c5",
+ "error.file.extension.missing":
+ "Λείπει η επέκταση για το \"{filename}\"",
+ "error.file.maxheight": "The height of the image must not exceed {height} pixels",
+ "error.file.maxsize": "The file is too large",
+ "error.file.maxwidth": "The width of the image must not exceed {width} pixels",
+ "error.file.mime.differs":
+ "Το αρχείο πρέπει να είναι του ίδιου τύπου mime \"{mime}\"",
+ "error.file.mime.forbidden": "Ο τύπος μέσου \"{mime}\" δεν επιτρέπεται",
+ "error.file.mime.invalid": "Invalid mime type: {mime}",
+ "error.file.mime.missing":
+ "Δεν είναι δυνατό να εντοπιστεί ο τύπος μέσου για το \"{filename}\"",
+ "error.file.minheight": "The height of the image must be at least {height} pixels",
+ "error.file.minsize": "The file is too small",
+ "error.file.minwidth": "The width of the image must be at least {width} pixels",
+ "error.file.name.missing": "Το όνομα αρχείου δεν μπορεί να είναι άδειο",
+ "error.file.notFound": "Δεν είναι δυνατό να βρεθεί το αρχείο \"{filename}\"",
+ "error.file.orientation": "The orientation of the image must be \"{orientation}\"",
+ "error.file.type.forbidden": "Δεν επιτρέπεται η μεταφόρτωση αρχείων {type}",
+ "error.file.undefined": "Δεν ήταν δυνατή η εύρεση του αρχείου",
+
+ "error.form.incomplete": "Παρακαλώ διορθώστε τα σφάλματα στη φόρμα...",
+ "error.form.notSaved": "Δεν ήταν δυνατή η αποθήκευση της φόρμας",
+
+ "error.language.code": "Please enter a valid code for the language",
+ "error.language.duplicate": "The language already exists",
+ "error.language.name": "Please enter a valid name for the language",
+
+ "error.license.format": "Please enter a valid license key",
+ "error.license.email": "Παρακαλώ εισάγετε μια έγκυρη διεύθυνση ηλεκτρονικού ταχυδρομείου",
+ "error.license.verification": "The license could not be verified",
+
+ "error.page.changeSlug.permission":
+ "Δεν επιτρέπεται να αλλάξετε το URL της σελίδας \"{slug}\"",
+ "error.page.changeStatus.incomplete":
+ "Δεν ήταν δυνατή η δημοσίευση της σελίδας καθώς περιέχει σφάλματα",
+ "error.page.changeStatus.permission":
+ "Δεν είναι δυνατή η αλλαγή κατάστασης για αυτή τη σελίδα",
+ "error.page.changeStatus.toDraft.invalid":
+ "Δεν είναι δυνατή η μετατροπή της σελίδας \"{slug}\" σε προσχέδιο",
+ "error.page.changeTemplate.invalid":
+ "Δεν είναι δυνατή η αλλαγή προτύπου για τη σελίδα \"{slug}\"",
+ "error.page.changeTemplate.permission":
+ "Δεν επιτρέπεται να αλλάξετε το πρότυπο για τη σελίδα \"{slug}\"",
+ "error.page.changeTitle.empty": "Ο τίτλος δεν μπορεί να είναι κενός",
+ "error.page.changeTitle.permission":
+ "Δεν επιτρέπεται να αλλάξετε τον τίτλο για τη σελίδα \"{slug}\"",
+ "error.page.create.permission": "Δεν επιτρέπεται να δημιουργήσετε τη σελίδα \"{slug}\"",
+ "error.page.delete": "Δεν είναι δυνατή η διαγραφή της σελίδας \"{slug}\"",
+ "error.page.delete.confirm": "Παρακαλώ εισάγετε τον τίτλο της σελίδας για επιβεβαίωση",
+ "error.page.delete.hasChildren":
+ "Δεν είναι δυνατή η διαγραφή της σελίδας καθώς περιέχει υποσελίδες",
+ "error.page.delete.permission": "Δεν επιτρέπεται η διαγραφή της σελίδας \"{slug}\"",
+ "error.page.draft.duplicate":
+ "Υπάρχει ήδη ένα προσχέδιο σελίδας με την διεύθυνση URL \"{slug}\"",
+ "error.page.duplicate":
+ "Υπάρχει ήδη μια σελίδα με την διεύθυνση URL \"{slug}\"",
+ "error.page.duplicate.permission": "You are not allowed to duplicate \"{slug}\"",
+ "error.page.notFound": "Δεν ήταν δυνατή η εύρεση της σελίδας \"{slug}\"",
+ "error.page.num.invalid":
+ "Παρακαλώ εισάγετε έναν έγκυρο αριθμό ταξινόμησης. Οι αριθμοί δεν μπορεί να είναι αρνητικοί.",
+ "error.page.slug.invalid": "Παρακαλώ εισάγετε ένα έγκυρο πρόθεμα διεύθυνσης URL",
+ "error.page.sort.permission": "Δεν είναι δυνατή η ταξινόμηση της σελίδας \"{slug}\"",
+ "error.page.status.invalid": "Ορίστε μια έγκυρη κατάσταση σελίδας",
+ "error.page.undefined": "Δεν ήταν δυνατή η εύρεση της σελίδας",
+ "error.page.update.permission": "Δεν επιτρέπεται η ενημέρωση της σελίδας \"{slug}\"",
+
+ "error.section.files.max.plural":
+ "Δεν πρέπει να προσθέσετε περισσότερα από {max} αρχεία στην ενότητα \"{section}\"",
+ "error.section.files.max.singular":
+ "Δεν πρέπει να προσθέσετε περισσότερα από ένα αρχεία στην ενότητα \"{section}\"",
+ "error.section.files.min.plural":
+ "The \"{section}\" section requires at least {min} files",
+ "error.section.files.min.singular":
+ "The \"{section}\" section requires at least one file",
+
+ "error.section.pages.max.plural":
+ "Δεν μπορείτε να προσθέσετε περισσότερες από {max} σελίδες στην ενότητα \"{section}\"",
+ "error.section.pages.max.singular":
+ "Δεν μπορείτε να προσθέσετε περισσότερες από μία σελίδες στην ενότητα \"{section}\"",
+ "error.section.pages.min.plural":
+ "The \"{section}\" section requires at least {min} pages",
+ "error.section.pages.min.singular":
+ "The \"{section}\" section requires at least one page",
+
+ "error.section.notLoaded": "Δεν ήταν δυνατή η φόρτωση της ενότητας \"{name}\"",
+ "error.section.type.invalid": "Ο τύπος ενότητας \"{type}\" δεν είναι έγκυρος",
+
+ "error.site.changeTitle.empty": "Ο τίτλος δεν μπορεί να είναι κενός",
+ "error.site.changeTitle.permission":
+ "Δεν επιτρέπεται να αλλάξετε τον τίτλο του ιστότοπου",
+ "error.site.update.permission": "Δεν επιτρέπεται η ενημέρωση του ιστότοπου",
+
+ "error.template.default.notFound": "Το προεπιλεγμένο πρότυπο δεν υπάρχει",
+
+ "error.user.changeEmail.permission":
+ "Δεν επιτρέπεται να αλλάξετε τη διεύθινση ηλεκτρονικού ταχυδρομείου για τον χρήστη \"{name}\"",
+ "error.user.changeLanguage.permission":
+ "Δεν επιτρέπεται να αλλάξετε τη γλώσσα για τον χρήστη \"{name}\"",
+ "error.user.changeName.permission":
+ "Δεν επιτρέπεται να αλλάξετε το όνομα του χρήστη \"{name}",
+ "error.user.changePassword.permission":
+ "Δεν επιτρέπεται να αλλάξετε τον κωδικό πρόσβασης για τον χρήστη \"{name}\"",
+ "error.user.changeRole.lastAdmin":
+ "Ο ρόλος του τελευταίου διαχειριστή δεν μπορεί να αλλάξει",
+ "error.user.changeRole.permission":
+ "Δεν επιτρέπεται να αλλάξετε το ρόλο του χρήστη \"{name}\"",
+ "error.user.changeRole.toAdmin":
+ "You are not allowed to promote someone to the admin role",
+ "error.user.create.permission": "Δεν επιτρέπεται η δημιουργία αυτού του χρήστη",
+ "error.user.delete": "\u039f \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7\u03c2 \u03b4\u03b5\u03bd \u03bc\u03c0\u03bf\u03c1\u03bf\u03cd\u03c3\u03b5 \u03bd\u03b1 \u03b4\u03b9\u03b1\u03b3\u03c1\u03b1\u03c6\u03b5\u03af",
+ "error.user.delete.lastAdmin": "Δεν είναι δυνατή η διαγραφή του τελευταίου διαχειριστή",
+ "error.user.delete.lastUser": "Δεν είναι δυνατή η διαγραφή του τελευταίου χρήστη",
+ "error.user.delete.permission":
+ "Δεν επιτρέπεται να διαγράψετ τον χρήστη \"{name}\"",
+ "error.user.duplicate":
+ "Ένας χρήστης με τη διεύθυνση ηλεκτρονικού ταχυδρομείου \"{email}\" υπάρχει ήδη",
+ "error.user.email.invalid": "Παρακαλώ εισάγετε μια έγκυρη διεύθυνση ηλεκτρονικού ταχυδρομείου",
+ "error.user.language.invalid": "Παρακαλώ εισαγάγετε μια έγκυρη γλώσσα",
+ "error.user.notFound": "Δεν είναι δυνατή η εύρεση του χρήστη \"{name}\"",
+ "error.user.password.invalid":
+ "Παρακαλώ εισάγετε έναν έγκυρο κωδικό πρόσβασης. Οι κωδικοί πρόσβασης πρέπει να έχουν μήκος τουλάχιστον 8 χαρακτήρων.",
+ "error.user.password.notSame": "\u03a0\u03b1\u03c1\u03b1\u03ba\u03b1\u03bb\u03bf\u03cd\u03bc\u03b5 \u03b5\u03c0\u03b9\u03b2\u03b5\u03b2\u03b1\u03b9\u03ce\u03c3\u03c4\u03b5 \u03c4\u03bf\u03bd \u039a\u03c9\u03b4\u03b9\u03ba\u03cc \u03a0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2",
+ "error.user.password.undefined": "Ο χρήστης δεν έχει κωδικό πρόσβασης",
+ "error.user.role.invalid": "Παρακαλώ εισαγάγετε έναν έγκυρο ρόλο",
+ "error.user.update.permission":
+ "Δεν επιτρέπεται η ενημέρωση του χρήστη \"{name}\"",
+
+ "error.validation.accepted": "Παρακαλώ επιβεβαιώστε",
+ "error.validation.alpha": "Παρακαλώ εισάγετε μόνο χαρακτήρες μεταξύ των a-z",
+ "error.validation.alphanum":
+ "Παρακαλώ εισάγετε μόνο χαρακτήρες μεταξύ των a-z ή αριθμούς απο το 0 έως το 9",
+ "error.validation.between":
+ "Παρακαλώ εισάγετε μια τιμή μεταξύ \"{min}\" και \"{max}\"",
+ "error.validation.boolean": "Παρακαλώ επιβεβαιώστε ή αρνηθείτε",
+ "error.validation.contains":
+ "Παρακαλώ καταχωρίστε μια τιμή που περιέχει \"{needle}\"",
+ "error.validation.date": "Παρακαλώ εισάγετε μία έγκυρη ημερομηνία",
+ "error.validation.date.after": "Please enter a date after {date}",
+ "error.validation.date.before": "Please enter a date before {date}",
+ "error.validation.date.between": "Please enter a date between {min} and {max}",
+ "error.validation.denied": "Παρακαλώ αρνηθείτε",
+ "error.validation.different": "Η τιμή δεν μπορεί να είναι \"{other}\"",
+ "error.validation.email": "Παρακαλώ εισάγετε μια έγκυρη διεύθυνση ηλεκτρονικού ταχυδρομείου",
+ "error.validation.endswith": "Η τιμή πρέπει να τελειώνει με \"{end}\"",
+ "error.validation.filename": "Παρακαλώ εισάγετε ένα έγκυρο όνομα αρχείου",
+ "error.validation.in": "Παρακαλώ εισάγετε ένα από τα παρακάτω: ({in})",
+ "error.validation.integer": "Παρακαλώ εισάγετε έναν έγκυρο ακέραιο αριθμό",
+ "error.validation.ip": "Παρακαλώ εισάγετε μια έγκυρη διεύθυνση IP",
+ "error.validation.less": "Παρακαλώ εισάγετε μια τιμή μικρότερη από {max}",
+ "error.validation.match": "Η τιμή δεν ταιριάζει με το αναμενόμενο πρότυπο",
+ "error.validation.max": "Παρακαλώ εισάγετε μια τιμή ίση ή μικρότερη από {max}",
+ "error.validation.maxlength":
+ "Παρακαλώ εισάγετε μια μικρότερη τιμή. (max. {max} χαρακτήρες)",
+ "error.validation.maxwords": "Παρακαλώ εισάγετε το πολύ {max} λέξεις",
+ "error.validation.min": "Παρακαλώ εισάγετε μια τιμή ίση ή μεγαλύτερη από {min}",
+ "error.validation.minlength":
+ "Παρακαλώ εισάγετε μεγαλύτερη τιμή. (τουλάχιστον {min} χαρακτήρες)",
+ "error.validation.minwords": "Παρακαλώ εισάγετε τουλάχιστον {min} λέξεις",
+ "error.validation.more": "Παρακαλώ εισάγετε τουλάχιστον {min} λέξεις",
+ "error.validation.notcontains":
+ "Παρακαλώ εισάγετε μια τιμή που δεν περιέχει \"{needle}\"",
+ "error.validation.notin":
+ "Παρακαλώ μην εισάγετε κανένα από τα παρακάτω: ({notIn})",
+ "error.validation.option": "Παρακαλώ κάντε μια έγκυρη επιλογή",
+ "error.validation.num": "Παρακαλώ εισάγετε έναν έγκυρο αριθμό",
+ "error.validation.required": "Παρακαλώ εισάγετε κάτι",
+ "error.validation.same": "Παρακαλώ εισάγετε \"{other}\"",
+ "error.validation.size": "Το μέγεθος της τιμής πρέπει να είναι \"{size}\"",
+ "error.validation.startswith": "Η τιμή πρέπει να αρχίζει με \"{start}\"",
+ "error.validation.time": "Παρακαλώ εισάγετε μια έγκυρη ώρα",
+ "error.validation.url": "Παρακαλώ εισάγετε μια έγκυρη διεύθυνση URL",
+
+ "field.required": "The field is required",
+ "field.files.empty": "Δεν έχουν επιλεγεί αρχεία ακόμα",
+ "field.pages.empty": "Δεν έχουν επιλεγεί ακόμη σελίδες",
+ "field.structure.delete.confirm": "\u0395\u03af\u03c3\u03c4\u03b5 \u03c3\u03af\u03b3\u03bf\u03c5\u03c1\u03bf\u03c2 \u03cc\u03c4\u03b9 \u03b8\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03b4\u03b9\u03b1\u03b3\u03c1\u03ac\u03c8\u03b5\u03c4\u03b5 \u03b1\u03c5\u03c4\u03ae\u03bd \u03c4\u03b7\u03bd \u03ba\u03b1\u03c4\u03b1\u03c7\u03ce\u03c1\u03b9\u03c3\u03b7;",
+ "field.structure.empty": "\u0394\u03b5\u03bd \u03c5\u03c0\u03ac\u03c1\u03c7\u03bf\u03c5\u03bd \u03b1\u03ba\u03cc\u03bc\u03b7 \u03ba\u03b1\u03c4\u03b1\u03c7\u03c9\u03c1\u03af\u03c3\u03b5\u03b9\u03c2.",
+ "field.users.empty": "Δεν έχουν επιλεγεί ακόμη χρήστες",
+
+ "file.delete.confirm":
+ "\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03c3\u03af\u03b3\u03bf\u03c5\u03c1\u03b1 \u03bd\u03b1 \u03b4\u03b9\u03b1\u03b3\u03c1\u03ac\u03c8\u03b5\u03c4\u03b5 \u03b1\u03c5\u03c4\u03cc \u03c4\u03bf \u03b1\u03c1\u03c7\u03b5\u03af\u03bf;",
+
+ "files": "Αρχεία",
+ "files.empty": "Δεν υπάρχουν ακόμα αρχεία",
+
+ "hour": "Ώρα",
+ "insert": "\u0395\u03b9\u03c3\u03b1\u03b3\u03c9\u03b3\u03ae",
+ "install": "Εγκατάσταση",
+
+ "installation": "Εγκατάσταση",
+ "installation.completed": "Ο πίνακας ελέγχου έχει εγκατασταθεί",
+ "installation.disabled": "Η εγκατάσταση του πίνακα ελέγχου είναι απενεργοποιημένη για δημόσιους διακομιστές από προεπιλογή. Εκτελέστε την εγκατάσταση σε ένα τοπικό μηχάνημα ή ενεργοποιήστε την με την επιλογή panel.install.",
+ "installation.issues.accounts":
+ "\u039f \u03c6\u03ac\u03ba\u03b5\u03bb\u03bf\u03c2 \/site\/accounts \u03b4\u03b5\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03b5\u03b3\u03b3\u03c1\u03ac\u03c8\u03b9\u03bc\u03bf\u03c2",
+ "installation.issues.content":
+ "\u039f \u03c6\u03ac\u03ba\u03b5\u03bb\u03bf\u03c2 content \u03ba\u03b1\u03b9 \u03cc\u03bb\u03bf\u03b9 \u03bf\u03b9 \u03c5\u03c0\u03bf\u03c6\u03ac\u03ba\u03b5\u03bb\u03bf\u03b9 \u03c0\u03c1\u03ad\u03c0\u03b5\u03b9 \u03bd\u03b1 \u03b5\u03af\u03bd\u03b1\u03b9 \u03b5\u03b3\u03b3\u03c1\u03ac\u03c8\u03b9\u03bc\u03bf\u03b9.",
+ "installation.issues.curl": "Απαιτείται η επέκταση CURL
",
+ "installation.issues.headline": "Ο πίνακας ελέγχου δεν μπορεί να εγκατασταθεί",
+ "installation.issues.mbstring":
+ "Απαιτείται η επέκταση MB String
",
+ "installation.issues.media":
+ "Ο φάκελος /media
δεν υπάρχει ή δεν είναι εγγράψιμος",
+ "installation.issues.php": "Βεβαιωθείτε ότι χρησιμοποιήτε PHP 7+
",
+ "installation.issues.server":
+ "To Kirby απαιτεί Apache
, Nginx
ή Caddy
",
+ "installation.issues.sessions": "Ο φάκελος /site/sessions
δεν υπάρχει ή δεν είναι εγγράψιμος",
+
+ "language": "\u0393\u03bb\u03ce\u03c3\u03c3\u03b1",
+ "language.code": "Κώδικας",
+ "language.convert": "Χρήση ως προεπιλογή",
+ "language.convert.confirm":
+ "
Όλες οι υποσελίδες θα διαγραφούν επίσης.",
+ "page.delete.confirm.title": "Εισάγετε τον τίτλο της σελίδας για επιβεβαίωση",
+ "page.draft.create": "Δημιουργία προσχεδίου",
+ "page.duplicate.appendix": "Αντιγραφή",
+ "page.duplicate.files": "Copy files",
+ "page.duplicate.pages": "Copy pages",
+ "page.status": "Kατάσταση",
+ "page.status.draft": "Προσχέδιο",
+ "page.status.draft.description":
+ "Η σελίδα είναι σε κατάσταση προσχεδίου και είναι ορατή μόνο για συνδεδεμένους συντάκτες",
+ "page.status.listed": "Δημοσιευμένο",
+ "page.status.listed.description": "Αυτή η σελίδα είναι δημοσιευμένη για οποιονδήποτε",
+ "page.status.unlisted": "Μη καταχωρημένο",
+ "page.status.unlisted.description": "Η σελίδα είναι προσβάσιμη μόνο μέσω της διεύθυνσης URL",
+
+ "pages": "Σελίδες",
+ "pages.empty": "Δεν υπάρχουν ακόμα σελίδες",
+ "pages.status.draft": "Προσχέδια",
+ "pages.status.listed": "Δημοσιευμένο",
+ "pages.status.unlisted": "Μη καταχωρημένο",
+
+ "pagination.page": "Σελίδα",
+
+ "password": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03a0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2",
+ "pixel": "Εικονοστοιχέιο",
+ "prev": "Προηγούμενο",
+ "remove": "Αφαίρεση",
+ "rename": "Μετονομασία",
+ "replace": "\u0391\u03bd\u03c4\u03b9\u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7",
+ "retry": "\u0395\u03c0\u03b1\u03bd\u03ac\u03bb\u03b7\u03c8\u03b7",
+ "revert": "\u0391\u03b3\u03bd\u03cc\u03b7\u03c3\u03b7",
+
+ "role": "\u03a1\u03cc\u03bb\u03bf\u03c2",
+ "role.admin.description": "The admin has all rights",
+ "role.admin.title": "Admin",
+ "role.all": "Όλα",
+ "role.empty": "Δεν υπάρχουν χρήστες με αυτόν τον ρόλο",
+ "role.description.placeholder": "Χωρίς περιγραφή",
+ "role.nobody.description": "This is a fallback role without any permissions",
+ "role.nobody.title": "Nobody",
+
+ "save": "\u0391\u03c0\u03bf\u03b8\u03ae\u03ba\u03b5\u03c5\u03c3\u03b7",
+ "search": "Αναζήτηση",
+
+ "section.required": "The section is required",
+
+ "select": "Επιλογή",
+ "settings": "Ρυθμίσεις",
+ "size": "Μέγεθος",
+ "slug": "\u0395\u03c0\u03af\u03b8\u03b5\u03bc\u03b1 URL",
+ "sort": "Ταξινόμηση",
+ "title": "Τίτλος",
+ "template": "\u03a0\u03c1\u03cc\u03c4\u03c5\u03c0\u03bf",
+ "today": "Σήμερα",
+
+ "toolbar.button.code": "Κώδικας",
+ "toolbar.button.bold": "\u0388\u03bd\u03c4\u03bf\u03bd\u03b7 \u03b3\u03c1\u03b1\u03c6\u03ae",
+ "toolbar.button.email": "Email",
+ "toolbar.button.headings": "Επικεφαλίδες",
+ "toolbar.button.heading.1": "Επικεφαλίδα 1",
+ "toolbar.button.heading.2": "Επικεφαλίδα 2",
+ "toolbar.button.heading.3": "Επικεφαλίδα 3",
+ "toolbar.button.italic": "\u03a0\u03bb\u03ac\u03b3\u03b9\u03b1 \u03b3\u03c1\u03b1\u03c6\u03ae",
+ "toolbar.button.file": "Αρχείο",
+ "toolbar.button.file.select": "Select a file",
+ "toolbar.button.file.upload": "Upload a file",
+ "toolbar.button.link": "\u03a3\u03cd\u03bd\u03b4\u03b5\u03c3\u03bc\u03bf\u03c2",
+ "toolbar.button.ol": "Ταξινομημένη λίστα",
+ "toolbar.button.ul": "Λίστα κουκκίδων",
+
+ "translation.author": "Ομάδα Kirby",
+ "translation.direction": "ltr",
+ "translation.name": "\u0395\u03bb\u03bb\u03b7\u03bd\u03b9\u03ba\u03ac",
+ "translation.locale": "el_GR",
+
+ "upload": "Μεταφόρτωση",
+ "upload.error.cantMove": "The uploaded file could not be moved",
+ "upload.error.cantWrite": "Failed to write file to disk",
+ "upload.error.default": "The file could not be uploaded",
+ "upload.error.extension": "File upload stopped by extension",
+ "upload.error.formSize": "The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the form",
+ "upload.error.iniPostSize": "The uploaded file exceeds the post_max_size directive in php.ini",
+ "upload.error.iniSize": "The uploaded file exceeds the upload_max_filesize directive in php.ini",
+ "upload.error.noFile": "No file was uploaded",
+ "upload.error.noFiles": "No files were uploaded",
+ "upload.error.partial": "The uploaded file was only partially uploaded",
+ "upload.error.tmpDir": "Missing a temporary folder",
+ "upload.errors": "Σφάλμα",
+ "upload.progress": "Μεταφόρτωση...",
+
+ "url": "Διεύθινση url",
+ "url.placeholder": "https://example.com",
+
+ "user": "Χρήστης",
+ "user.blueprint":
+ "Μπορείτε να ορίσετε επιπλέον τμήματα και πεδία φόρμας για αυτόν τον ρόλο χρήστη στο /site/blueprints/users/{role}.yml",
+ "user.changeEmail": "Αλλαγή διεύθινσης ηλεκτρονικού ταχυδρομείου",
+ "user.changeLanguage": "Αλλαγή γλώσσας",
+ "user.changeName": "Μετονομασία χρήστη",
+ "user.changePassword": "Αλλαγή κωδικού πρόσβασης",
+ "user.changePassword.new": "Νέος Κωδικός Πρόσβασης",
+ "user.changePassword.new.confirm": "Επαληθεύση κωδικού πρόσβασης",
+ "user.changeRole": "Αλλαγή ρόλου",
+ "user.changeRole.select": "Επιλογή νέου ρόλου",
+ "user.create": "Προσθήκη νέου χρήστη",
+ "user.delete": "Διαγραφή χρήστη",
+ "user.delete.confirm":
+ "\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03c3\u03af\u03b3\u03bf\u03c5\u03c1\u03b1 \u03bd\u03b1 \u03b4\u03b9\u03b1\u03b3\u03c1\u03ac\u03c8\u03b5\u03c4\u03b5 \u03b1\u03c5\u03c4\u03cc\u03bd \u03c4\u03bf\u03bd \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7;",
+
+ "users": "Χρήστες",
+
+ "version": "\u0388\u03ba\u03b4\u03bf\u03c3\u03b7 Kirby",
+
+ "view.account": "\u039f \u03bb\u03bf\u03b3\u03b1\u03c1\u03b9\u03b1\u03c3\u03bc\u03cc\u03c2 \u03c3\u03b1\u03c2",
+ "view.installation": "\u0395\u03b3\u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7",
+ "view.settings": "Ρυθμίσεις",
+ "view.site": "Iστοσελίδα",
+ "view.users": "\u03a7\u03c1\u03ae\u03c3\u03c4\u03b5\u03c2",
+
+ "welcome": "Καλώς ήρθατε",
+ "year": "Έτος"
+}
diff --git a/kirby/i18n/translations/en.json b/kirby/i18n/translations/en.json
new file mode 100755
index 0000000..173dc0c
--- /dev/null
+++ b/kirby/i18n/translations/en.json
@@ -0,0 +1,481 @@
+{
+ "add": "Add",
+ "avatar": "Profile picture",
+ "back": "Back",
+ "cancel": "Cancel",
+ "change": "Change",
+ "close": "Close",
+ "confirm": "Ok",
+ "copy": "Copy",
+ "create": "Create",
+
+ "date": "Date",
+ "date.select": "Select a date",
+
+ "day": "Day",
+ "days.fri": "Fri",
+ "days.mon": "Mon",
+ "days.sat": "Sat",
+ "days.sun": "Sun",
+ "days.thu": "Thu",
+ "days.tue": "Tue",
+ "days.wed": "Wed",
+
+ "delete": "Delete",
+ "dimensions": "Dimensions",
+ "disabled": "Disabled",
+ "discard": "Discard",
+ "download": "Download",
+ "duplicate": "Duplicate",
+ "edit": "Edit",
+
+ "dialog.files.empty": "No files to select",
+ "dialog.pages.empty": "No pages to select",
+ "dialog.users.empty": "No users to select",
+
+ "email": "Email",
+ "email.placeholder": "mail@example.com",
+
+ "error.access.login": "Invalid login",
+ "error.access.panel": "You are not allowed to access the panel",
+ "error.access.view": "You are not allowed to access this part of the panel",
+
+ "error.avatar.create.fail": "The profile picture could not be uploaded",
+ "error.avatar.delete.fail": "The profile picture could not be deleted",
+ "error.avatar.dimensions.invalid":
+ "Please keep the width and height of the profile picture under 3000 pixels",
+ "error.avatar.mime.forbidden":
+ "The profile picture must be JPEG or PNG files",
+
+ "error.blueprint.notFound": "The blueprint \"{name}\" could not be loaded",
+
+ "error.email.preset.notFound": "The email preset \"{name}\" cannot be found",
+
+ "error.field.converter.invalid": "Invalid converter \"{converter}\"",
+
+ "error.file.changeName.empty": "The name must not be empty",
+ "error.file.changeName.permission":
+ "You are not allowed to change the name of \"{filename}\"",
+ "error.file.duplicate": "A file with the name \"{filename}\" already exists",
+ "error.file.extension.forbidden":
+ "The extension \"{extension}\" is not allowed",
+ "error.file.extension.missing":
+ "The extensions for \"{filename}\" is missing",
+ "error.file.maxheight": "The height of the image must not exceed {height} pixels",
+ "error.file.maxsize": "The file is too large",
+ "error.file.maxwidth": "The width of the image must not exceed {width} pixels",
+ "error.file.mime.differs":
+ "The uploaded file must be of the same mime type \"{mime}\"",
+ "error.file.mime.forbidden": "The media type \"{mime}\" is not allowed",
+ "error.file.mime.invalid": "Invalid mime type: {mime}",
+ "error.file.mime.missing":
+ "The media type for \"{filename}\" cannot be detected",
+ "error.file.minheight": "The height of the image must be at least {height} pixels",
+ "error.file.minsize": "The file is too small",
+ "error.file.minwidth": "The width of the image must be at least {width} pixels",
+ "error.file.name.missing": "The filename must not be empty",
+ "error.file.notFound": "The file \"{filename}\" cannot be found",
+ "error.file.orientation": "The orientation of the image must be \"{orientation}\"",
+ "error.file.type.forbidden": "You are not allowed to upload {type} files",
+ "error.file.undefined": "The file cannot be found",
+
+ "error.form.incomplete": "Please fix all form errors…",
+ "error.form.notSaved": "The form could not be saved",
+
+ "error.language.code": "Please enter a valid code for the language",
+ "error.language.duplicate": "The language already exists",
+ "error.language.name": "Please enter a valid name for the language",
+
+ "error.license.format": "Please enter a valid license key",
+ "error.license.email": "Please enter a valid email address",
+ "error.license.verification": "The license could not be verified",
+
+ "error.page.changeSlug.permission":
+ "You are not allowed to change the URL appendix for \"{slug}\"",
+ "error.page.changeStatus.incomplete":
+ "The page has errors and cannot be published",
+ "error.page.changeStatus.permission":
+ "The status for this page cannot be changed",
+ "error.page.changeStatus.toDraft.invalid":
+ "The page \"{slug}\" cannot be converted to a draft",
+ "error.page.changeTemplate.invalid":
+ "The template for the page \"{slug}\" cannot be changed",
+ "error.page.changeTemplate.permission":
+ "You are not allowed to change the template for \"{slug}\"",
+ "error.page.changeTitle.empty": "The title must not be empty",
+ "error.page.changeTitle.permission":
+ "You are not allowed to change the title for \"{slug}\"",
+ "error.page.create.permission": "You are not allowed to create \"{slug}\"",
+ "error.page.delete": "The page \"{slug}\" cannot be deleted",
+ "error.page.delete.confirm": "Please enter the page title to confirm",
+ "error.page.delete.hasChildren":
+ "The page has subpages and cannot be deleted",
+ "error.page.delete.permission": "You are not allowed to delete \"{slug}\"",
+ "error.page.draft.duplicate":
+ "A page draft with the URL appendix \"{slug}\" already exists",
+ "error.page.duplicate":
+ "A page with the URL appendix \"{slug}\" already exists",
+ "error.page.duplicate.permission": "You are not allowed to duplicate \"{slug}\"",
+ "error.page.notFound": "The page \"{slug}\" cannot be found",
+ "error.page.num.invalid":
+ "Please enter a valid sorting number. Numbers must not be negative.",
+ "error.page.slug.invalid": "Please enter a valid URL prefix",
+ "error.page.sort.permission": "The page \"{slug}\" cannot be sorted",
+ "error.page.status.invalid": "Please set a valid page status",
+ "error.page.undefined": "The page cannot be found",
+ "error.page.update.permission": "You are not allowed to update \"{slug}\"",
+
+ "error.section.files.max.plural":
+ "You must not add more than {max} files to the \"{section}\" section",
+ "error.section.files.max.singular":
+ "You must not add more than one file to the \"{section}\" section",
+ "error.section.files.min.plural":
+ "The \"{section}\" section requires at least {min} files",
+ "error.section.files.min.singular":
+ "The \"{section}\" section requires at least one file",
+
+ "error.section.pages.max.plural":
+ "You must not add more than {max} pages to the \"{section}\" section",
+ "error.section.pages.max.singular":
+ "You must not add more than one page to the \"{section}\" section",
+ "error.section.pages.min.plural":
+ "The \"{section}\" section requires at least {min} pages",
+ "error.section.pages.min.singular":
+ "The \"{section}\" section requires at least one page",
+
+ "error.section.notLoaded": "The section \"{name}\" could not be loaded",
+ "error.section.type.invalid": "The section type \"{type}\" is not valid",
+
+ "error.site.changeTitle.empty": "The title must not be empty",
+ "error.site.changeTitle.permission":
+ "You are not allowed to change the title of the site",
+ "error.site.update.permission": "You are not allowed to update the site",
+
+ "error.template.default.notFound": "The default template does not exist",
+
+ "error.user.changeEmail.permission":
+ "You are not allowed to change the email for the user \"{name}\"",
+ "error.user.changeLanguage.permission":
+ "You are not allowed to change the language for the user \"{name}\"",
+ "error.user.changeName.permission":
+ "You are not allowed to change the name for the user \"{name}\"",
+ "error.user.changePassword.permission":
+ "You are not allowed to change the password for the user \"{name}\"",
+ "error.user.changeRole.lastAdmin":
+ "The role for the last admin cannot be changed",
+ "error.user.changeRole.permission":
+ "You are not allowed to change the role for the user \"{name}\"",
+ "error.user.changeRole.toAdmin":
+ "You are not allowed to promote someone to the admin role",
+ "error.user.create.permission": "You are not allowed to create this user",
+ "error.user.delete": "The user \"{name}\" cannot be deleted",
+ "error.user.delete.lastAdmin": "The last admin cannot be deleted",
+ "error.user.delete.lastUser": "The last user cannot be deleted",
+ "error.user.delete.permission":
+ "You are not allowed to delete the user \"{name}\"",
+ "error.user.duplicate":
+ "A user with the email address \"{email}\" already exists",
+ "error.user.email.invalid": "Please enter a valid email address",
+ "error.user.language.invalid": "Please enter a valid language",
+ "error.user.notFound": "The user \"{name}\" cannot be found",
+ "error.user.password.invalid":
+ "Please enter a valid password. Passwords must be at least 8 characters long.",
+ "error.user.password.notSame": "The passwords do not match",
+ "error.user.password.undefined": "The user does not have a password",
+ "error.user.role.invalid": "Please enter a valid role",
+ "error.user.update.permission":
+ "You are not allowed to update the user \"{name}\"",
+
+ "error.validation.accepted": "Please confirm",
+ "error.validation.alpha": "Please only enter characters between a-z",
+ "error.validation.alphanum":
+ "Please only enter characters between a-z or numerals 0-9",
+ "error.validation.between":
+ "Please enter a value between \"{min}\" and \"{max}\"",
+ "error.validation.boolean": "Please confirm or deny",
+ "error.validation.contains":
+ "Please enter a value that contains \"{needle}\"",
+ "error.validation.date": "Please enter a valid date",
+ "error.validation.date.after": "Please enter a date after {date}",
+ "error.validation.date.before": "Please enter a date before {date}",
+ "error.validation.date.between": "Please enter a date between {min} and {max}",
+ "error.validation.denied": "Please deny",
+ "error.validation.different": "The value must not be \"{other}\"",
+ "error.validation.email": "Please enter a valid email address",
+ "error.validation.endswith": "The value must end with \"{end}\"",
+ "error.validation.filename": "Please enter a valid filename",
+ "error.validation.in": "Please enter one of the following: ({in})",
+ "error.validation.integer": "Please enter a valid integer",
+ "error.validation.ip": "Please enter a valid IP address",
+ "error.validation.less": "Please enter a value lower than {max}",
+ "error.validation.match": "The value does not match the expected pattern",
+ "error.validation.max": "Please enter a value equal to or lower than {max}",
+ "error.validation.maxlength":
+ "Please enter a shorter value. (max. {max} characters)",
+ "error.validation.maxwords": "Please enter no more than {max} word(s)",
+ "error.validation.min": "Please enter a value equal to or greater than {min}",
+ "error.validation.minlength":
+ "Please enter a longer value. (min. {min} characters)",
+ "error.validation.minwords": "Please enter at least {min} word(s)",
+ "error.validation.more": "Please enter a greater value than {min}",
+ "error.validation.notcontains":
+ "Please enter a value that does not contain \"{needle}\"",
+ "error.validation.notin":
+ "Please don't enter any of the following: ({notIn})",
+ "error.validation.option": "Please select a valid option",
+ "error.validation.num": "Please enter a valid number",
+ "error.validation.required": "Please enter something",
+ "error.validation.same": "Please enter \"{other}\"",
+ "error.validation.size": "The size of the value must be \"{size}\"",
+ "error.validation.startswith": "The value must start with \"{start}\"",
+ "error.validation.time": "Please enter a valid time",
+ "error.validation.url": "Please enter a valid URL",
+
+ "field.required": "The field is required",
+ "field.files.empty": "No files selected yet",
+ "field.pages.empty": "No pages selected yet",
+ "field.structure.delete.confirm": "Do you really want to delete this row?",
+ "field.structure.empty": "No entries yet",
+ "field.users.empty": "No users selected yet",
+
+ "file.delete.confirm":
+ "Do you really want to delete
{filename}?",
+
+ "files": "Files",
+ "files.empty": "No files yet",
+
+ "hour": "Hour",
+ "insert": "Insert",
+ "install": "Install",
+
+ "installation": "Installation",
+ "installation.completed": "The panel has been installed",
+ "installation.disabled": "The panel installer is disabled on public servers by default. Please run the installer on a local machine or enable it with the panel.install
option.",
+ "installation.issues.accounts":
+ "The /site/accounts
folder does not exist or is not writable",
+ "installation.issues.content":
+ "The /content
folder does not exist or is not writable",
+ "installation.issues.curl": "The CURL
extension is required",
+ "installation.issues.headline": "The panel cannot be installed",
+ "installation.issues.mbstring":
+ "The MB String
extension is required",
+ "installation.issues.media":
+ "The /media
folder does not exist or is not writable",
+ "installation.issues.php": "Make sure to use PHP 7+
",
+ "installation.issues.server":
+ "Kirby requires Apache
, Nginx
or Caddy
",
+ "installation.issues.sessions": "The /site/sessions
folder does not exist or is not writable",
+
+ "language": "Language",
+ "language.code": "Code",
+ "language.convert": "Make default",
+ "language.convert.confirm":
+ "
All subpages will be deleted as well.",
+ "page.delete.confirm.title": "Enter the page title to confirm",
+ "page.draft.create": "Create draft",
+ "page.duplicate.appendix": "Copy",
+ "page.duplicate.files": "Copy files",
+ "page.duplicate.pages": "Copy pages",
+ "page.status": "Status",
+ "page.status.draft": "Draft",
+ "page.status.draft.description":
+ "The page is in draft mode and only visible for logged in editors",
+ "page.status.listed": "Public",
+ "page.status.listed.description": "The page is public for anyone",
+ "page.status.unlisted": "Unlisted",
+ "page.status.unlisted.description": "The page is only accessible via URL",
+
+ "pages": "Pages",
+ "pages.empty": "No pages yet",
+ "pages.status.draft": "Drafts",
+ "pages.status.listed": "Published",
+ "pages.status.unlisted": "Unlisted",
+
+ "pagination.page": "Page",
+
+ "password": "Password",
+ "pixel": "Pixel",
+ "prev": "Previous",
+ "remove": "Remove",
+ "rename": "Rename",
+ "replace": "Replace",
+ "retry": "Try again",
+ "revert": "Revert",
+
+ "role": "Role",
+ "role.admin.description": "The admin has all rights",
+ "role.admin.title": "Admin",
+ "role.all": "All",
+ "role.empty": "There are no users with this role",
+ "role.description.placeholder": "No description",
+ "role.nobody.description": "This is a fallback role without any permissions",
+ "role.nobody.title": "Nobody",
+
+ "save": "Save",
+ "search": "Search",
+
+ "section.required": "The section is required",
+
+ "select": "Select",
+ "settings": "Settings",
+ "size": "Size",
+ "slug": "URL appendix",
+ "sort": "Sort",
+ "title": "Title",
+ "template": "Template",
+ "today": "Today",
+
+ "toolbar.button.code": "Code",
+ "toolbar.button.bold": "Bold",
+ "toolbar.button.email": "Email",
+ "toolbar.button.headings": "Headings",
+ "toolbar.button.heading.1": "Heading 1",
+ "toolbar.button.heading.2": "Heading 2",
+ "toolbar.button.heading.3": "Heading 3",
+ "toolbar.button.italic": "Italic",
+ "toolbar.button.file": "File",
+ "toolbar.button.file.select": "Select a file",
+ "toolbar.button.file.upload": "Upload a file",
+ "toolbar.button.link": "Link",
+ "toolbar.button.ol": "Ordered list",
+ "toolbar.button.ul": "Bullet list",
+
+ "translation.author": "Kirby Team",
+ "translation.direction": "ltr",
+ "translation.name": "English",
+ "translation.locale": "en_US",
+
+ "upload": "Upload",
+ "upload.error.cantMove": "The uploaded file could not be moved",
+ "upload.error.cantWrite": "Failed to write file to disk",
+ "upload.error.default": "The file could not be uploaded",
+ "upload.error.extension": "File upload stopped by extension",
+ "upload.error.formSize": "The uploaded file exceeds the MAX_FILE_SIZE directive that was specified in the form",
+ "upload.error.iniPostSize": "The uploaded file exceeds the post_max_size directive in php.ini",
+ "upload.error.iniSize": "The uploaded file exceeds the upload_max_filesize directive in php.ini",
+ "upload.error.noFile": "No file was uploaded",
+ "upload.error.noFiles": "No files were uploaded",
+ "upload.error.partial": "The uploaded file was only partially uploaded",
+ "upload.error.tmpDir": "Missing a temporary folder",
+ "upload.errors": "Error",
+ "upload.progress": "Uploading…",
+
+ "url": "Url",
+ "url.placeholder": "https://example.com",
+
+ "user": "User",
+ "user.blueprint":
+ "You can define additional sections and form fields for this user role in /site/blueprints/users/{role}.yml",
+ "user.changeEmail": "Change email",
+ "user.changeLanguage": "Change language",
+ "user.changeName": "Rename this user",
+ "user.changePassword": "Change password",
+ "user.changePassword.new": "New password",
+ "user.changePassword.new.confirm": "Confirm the new password…",
+ "user.changeRole": "Change role",
+ "user.changeRole.select": "Select a new role",
+ "user.create": "Add a new user",
+ "user.delete": "Delete this user",
+ "user.delete.confirm":
+ "Do you really want to delete
{email}?",
+
+ "users": "Users",
+
+ "version": "Version",
+
+ "view.account": "Your account",
+ "view.installation": "Installation",
+ "view.settings": "Settings",
+ "view.site": "Site",
+ "view.users": "Users",
+
+ "welcome": "Welcome",
+ "year": "Year"
+}
diff --git a/kirby/i18n/translations/es_419.json b/kirby/i18n/translations/es_419.json
new file mode 100755
index 0000000..0d476b7
--- /dev/null
+++ b/kirby/i18n/translations/es_419.json
@@ -0,0 +1,481 @@
+{
+ "add": "Agregar",
+ "avatar": "Foto de perfil",
+ "back": "Regresar",
+ "cancel": "Cancelar",
+ "change": "Cambiar",
+ "close": "Cerrar",
+ "confirm": "De acuerdo",
+ "copy": "Copiar",
+ "create": "Crear",
+
+ "date": "Fecha",
+ "date.select": "Selecciona una fecha",
+
+ "day": "Día",
+ "days.fri": "Vie",
+ "days.mon": "Lun",
+ "days.sat": "S\u00e1b",
+ "days.sun": "Dom",
+ "days.thu": "Jue",
+ "days.tue": "Mar",
+ "days.wed": "Mi\u00e9",
+
+ "delete": "Eliminar",
+ "dimensions": "Dimensiones",
+ "disabled": "Desabilitado",
+ "discard": "Descartar",
+ "download": "Descargar",
+ "duplicate": "Duplicar",
+ "edit": "Editar",
+
+ "dialog.files.empty": "No has seleccionado ningún archivo",
+ "dialog.pages.empty": "No has seleccionado ninguna página",
+ "dialog.users.empty": "No has seleccionado ningún usuario",
+
+ "email": "Correo Electrónico",
+ "email.placeholder": "correo@ejemplo.com",
+
+ "error.access.login": "Ingreso inválido",
+ "error.access.panel": "No tienes permitido acceder al panel.",
+ "error.access.view": "No tienes permiso para acceder a esta parte del panel",
+
+ "error.avatar.create.fail": "No se pudo subir la foto de perfil.",
+ "error.avatar.delete.fail": "No se pudo eliminar la foto de perfil.",
+ "error.avatar.dimensions.invalid":
+ "Por favor, mantén el ancho y la altura de la imagen de perfil por debajo de 3000 pixeles.",
+ "error.avatar.mime.forbidden":
+ "La foto de perfil debe de ser un archivo JPG o PNG.",
+
+ "error.blueprint.notFound": "El blueprint \"{name}\" no se pudo cargar.",
+
+ "error.email.preset.notFound": "El preajuste de email \"{name}\" no se pudo encontrar.",
+
+ "error.field.converter.invalid": "Convertidor inválido \"{converter}\"",
+
+ "error.file.changeName.empty": "El nombre no debe estar vacío",
+ "error.file.changeName.permission":
+ "No tienes permitido cambiar el nombre de \"{filename}\"",
+ "error.file.duplicate": "Ya existe un archivo con el nombre \"{filename}\".",
+ "error.file.extension.forbidden":
+ "La extensión \"{extension}\" no está permitida.",
+ "error.file.extension.missing":
+ "Falta la extensión para \"{filename}\".",
+ "error.file.maxheight": "La altura de la imagen no debe exceder {height} pixeles",
+ "error.file.maxsize": "El archivo es muy grande",
+ "error.file.maxwidth": "El ancho de la imagen no debe exceder {width} pixeles",
+ "error.file.mime.differs":
+ "El archivo cargado debe ser del mismo tipo mime \"{mime}\".",
+ "error.file.mime.forbidden": "El tipo de medios \"{mime}\" no está permitido.",
+ "error.file.mime.invalid": "Tipo invalido de mime: {mime}",
+ "error.file.mime.missing":
+ "No se puede detectar el tipo de medio para \"{filename}\".",
+ "error.file.minheight": "La altura de la imagen debe ser de al menos {height} pixeles",
+ "error.file.minsize": "El archivo es muy pequeño",
+ "error.file.minwidth": "El ancho de la imagen debe ser de al menos {width} pixeles",
+ "error.file.name.missing": "El nombre del archivo no debe estar vacío.",
+ "error.file.notFound": "El archivo \"{filename}\" no pudo ser encontrado.",
+ "error.file.orientation": "La orientación de la imagen debe ser \"{orientation}\"",
+ "error.file.type.forbidden": "No está permitido subir archivos {type}.",
+ "error.file.undefined": "El archivo no se puede encontrar.",
+
+ "error.form.incomplete": "Por favor, corrige todos los errores del formulario...",
+ "error.form.notSaved": "No se pudo guardar el formulario.",
+
+ "error.language.code": "Por favor introduce un código válido para el lenguaje",
+ "error.language.duplicate": "El lenguaje ya existe",
+ "error.language.name": "Por favor introduce un nombre válido para el lenguaje",
+
+ "error.license.format": "Por favor introduce una llave de licencia válida",
+ "error.license.email": "Por favor ingresa un correo electrónico valido",
+ "error.license.verification": "La licencia no pude ser verificada",
+
+ "error.page.changeSlug.permission":
+ "No está permitido cambiar el apéndice de URL para \"{slug}\".",
+ "error.page.changeStatus.incomplete":
+ "La página tiene errores y no puede ser publicada.",
+ "error.page.changeStatus.permission":
+ "El estado de esta página no se puede cambiar.",
+ "error.page.changeStatus.toDraft.invalid":
+ "La página \"{slug}\" no se puede convertir en un borrador",
+ "error.page.changeTemplate.invalid":
+ "La plantilla para la página \"{slug}\" no se puede cambiar",
+ "error.page.changeTemplate.permission":
+ "No está permitido cambiar la plantilla para \"{slug}\"",
+ "error.page.changeTitle.empty": "El título no debe estar vacío.",
+ "error.page.changeTitle.permission":
+ "No tienes permiso para cambiar el título de \"{slug}\"",
+ "error.page.create.permission": "No tienes permiso para crear \"{slug}\"",
+ "error.page.delete": "La página \"{slug}\" no se puede eliminar",
+ "error.page.delete.confirm": "Por favor, introduce el título de la página para confirmar",
+ "error.page.delete.hasChildren":
+ "La página tiene subpáginas y no se puede eliminar",
+ "error.page.delete.permission": "No tienes permiso para borrar \"{slug}\"",
+ "error.page.draft.duplicate":
+ "Ya existe un borrador de página con el apéndice de URL \"{slug}\"",
+ "error.page.duplicate":
+ "Ya existe una página con el apéndice de URL \"{slug}\"",
+ "error.page.duplicate.permission": "No tienes permitido duplicar \"{slug}\"",
+ "error.page.notFound": "La página \"{slug}\" no se encuentra",
+ "error.page.num.invalid":
+ "Por favor, introduce un número de posición válido. Los números no deben ser negativos.",
+ "error.page.slug.invalid": "Por favor ingresa un prefijo de URL válido",
+ "error.page.sort.permission": "La página \"{slug}\" no se puede ordenar",
+ "error.page.status.invalid": "Por favor, establece una estado de página válido",
+ "error.page.undefined": "La p\u00e1gina no fue encontrada",
+ "error.page.update.permission": "No tienes permiso para actualizar \"{slug}\"",
+
+ "error.section.files.max.plural":
+ "No debes agregar más de {max} archivos a la sección \"{section}\"",
+ "error.section.files.max.singular":
+ "No debes agregar más de un archivo a la sección \"{section}\"",
+ "error.section.files.min.plural":
+ "La sección \"{section}\" requiere al menos {min} archivos",
+ "error.section.files.min.singular":
+ "La sección \"{section}\" requiere al menos un archivo",
+
+ "error.section.pages.max.plural":
+ "No debes agregar más de {max} páginas a la sección \"{section}\"",
+ "error.section.pages.max.singular":
+ "No debes agregar más de una página a la sección \"{section}\"",
+ "error.section.pages.min.plural":
+ "La sección \"{section}\" requiere al menos {min} páginas",
+ "error.section.pages.min.singular":
+ "La sección \"{section}\" requiere al menos una página",
+
+ "error.section.notLoaded": "La sección \"{name}\" no se pudo cargar",
+ "error.section.type.invalid": "La sección \"{type}\" no es valida",
+
+ "error.site.changeTitle.empty": "El título no debe estar vacío.",
+ "error.site.changeTitle.permission":
+ "No tienes permiso para cambiar el título del sitio",
+ "error.site.update.permission": "No tienes permiso de actualizar el sitio",
+
+ "error.template.default.notFound": "La plantilla predeterminada no existe",
+
+ "error.user.changeEmail.permission":
+ "No tienes permiso para cambiar el email del usuario \"{name}\"",
+ "error.user.changeLanguage.permission":
+ "No tienes permiso para cambiar el idioma del usuario \"{name}\"",
+ "error.user.changeName.permission":
+ "No tienes permiso para cambiar el nombre del usuario \"{name}\"",
+ "error.user.changePassword.permission":
+ "No tienes permiso para cambiar la contraseña del usuario \"{name}\"",
+ "error.user.changeRole.lastAdmin":
+ "El rol del último administrador no puede ser cambiado",
+ "error.user.changeRole.permission":
+ "No tienes permiso para cambiar el rol del usuario \"{name}\"",
+ "error.user.changeRole.toAdmin":
+ "No tienes permitido promover a alguien al rol de admin",
+ "error.user.create.permission": "No tienes permiso de crear este usuario",
+ "error.user.delete": "El ususario no pudo ser eliminado",
+ "error.user.delete.lastAdmin": "Usted no puede borrar el \u00faltimo administrador",
+ "error.user.delete.lastUser": "El último usuario no puede ser borrado",
+ "error.user.delete.permission":
+ "Usted no tiene permitido borrar este usuario",
+ "error.user.duplicate":
+ "Ya existe un usuario con el email \"{email}\"",
+ "error.user.email.invalid": "Por favor ingresa un correo electrónico valido",
+ "error.user.language.invalid": "Por favor ingresa un idioma valido",
+ "error.user.notFound": "El usuario no pudo ser encontrado",
+ "error.user.password.invalid":
+ "Por favor ingresa una contraseña valida. Las contraseñas deben tener al menos 8 caracteres de largo.",
+ "error.user.password.notSame": "Por favor confirma la contrase\u00f1a",
+ "error.user.password.undefined": "El usuario no tiene contraseña",
+ "error.user.role.invalid": "Por favor ingresa un rol valido",
+ "error.user.update.permission":
+ "No tienes permiso para actualizar al usuario \"{name}\"",
+
+ "error.validation.accepted": "Por favor, confirma",
+ "error.validation.alpha": "Por favor ingrese solo caracteres entre a-z",
+ "error.validation.alphanum":
+ "Por favor ingrese solo caracteres entre a-z o números entre 0-9",
+ "error.validation.between":
+ "Por favor ingrese valores entre \"{min}\" y \"{max}\"",
+ "error.validation.boolean": "Por favor confirme o niegue",
+ "error.validation.contains":
+ "Por favor ingrese valores que contengan \"{needle}\"",
+ "error.validation.date": "Por favor ingresa una fecha válida",
+ "error.validation.date.after": "Por favor introduce una fecha posterior a {date}",
+ "error.validation.date.before": "Por favor introduce una fecha anterior a {date}",
+ "error.validation.date.between": "Por favor introduce un número entre {min} y {max}",
+ "error.validation.denied": "Por favor niegue",
+ "error.validation.different": "EL valor no debe ser \"{other}\"",
+ "error.validation.email": "Por favor ingresa un correo electrónico valido",
+ "error.validation.endswith": "El valor no debe terminar con \"{end}\"",
+ "error.validation.filename": "Por favor ingresa un nombre de archivo válido",
+ "error.validation.in": "Por favor ingresa uno de los siguientes: ({in})",
+ "error.validation.integer": "Por favor ingresa un entero válido",
+ "error.validation.ip": "Por favor ingresa una dirección IP válida",
+ "error.validation.less": "Por favor ingresa un valor menor a {max}",
+ "error.validation.match": "El valor no coincide con el patrón esperado",
+ "error.validation.max": "Por favor ingresa un valor menor o igual a {max}",
+ "error.validation.maxlength":
+ "Por favor ingresa un valor mas corto. (max. {max} caracteres)",
+ "error.validation.maxwords": "Por favor ingresa no mas de {max} palabra(s)",
+ "error.validation.min": "Por favor ingresa un valor mayor o igual a {min}",
+ "error.validation.minlength":
+ "Por favor ingresa un valor mas largo. (min. {min} caracteres)",
+ "error.validation.minwords": "Por favor ingresa al menos {min} palabra(s)",
+ "error.validation.more": "Por favor ingresa un valor mayor a {min}",
+ "error.validation.notcontains":
+ "Por favor ingresa un valor que no contenga \"{needle}\"",
+ "error.validation.notin":
+ "Por favor no ingreses ninguno de las siguientes: ({notIn})",
+ "error.validation.option": "Por favor selecciona una de las opciones válidas",
+ "error.validation.num": "Por favor ingresa un numero válido",
+ "error.validation.required": "Por favor ingresa algo",
+ "error.validation.same": "Por favor ingresa \"{other}\"",
+ "error.validation.size": "El tamaño del valor debe ser \"{size}\"",
+ "error.validation.startswith": "El valor debe comenzar con \"{start}\"",
+ "error.validation.time": "Por favor ingresa una hora válida",
+ "error.validation.url": "Por favor ingresa un URL válido",
+
+ "field.required": "Este campo es requerido",
+ "field.files.empty": "Aún no ha seleccionado ningún archivo",
+ "field.pages.empty": "Aún no ha seleccionado ningúna pagina",
+ "field.structure.delete.confirm": "\u00bfEn realidad desea borrar esta entrada?",
+ "field.structure.empty": "A\u00fan no existen entradas.",
+ "field.users.empty": "Aún no ha seleccionado ningún usuario",
+
+ "file.delete.confirm":
+ "\u00bfEst\u00e1s seguro que deseas eliminar este archivo?",
+
+ "files": "Archivos",
+ "files.empty": "Aún no existen archivos",
+
+ "hour": "Hora",
+ "insert": "Insertar",
+ "install": "Instalar",
+
+ "installation": "Instalación",
+ "installation.completed": "El panel ha sido instalado.",
+ "installation.disabled": "El instalador del panel está deshabilitado en servidores públicos por defecto. Ejecute el instalador en una máquina local o habilítelo con la opción panel.install.",
+ "installation.issues.accounts":
+ "La carpeta /site/accounts
no existe o no posee permisos de escritura.",
+ "installation.issues.content":
+ "La carpeta /content
no existe o no posee permisos de escritura.",
+ "installation.issues.curl": "Se requiere la extensión CURL
.",
+ "installation.issues.headline": "El panel no puede ser instalado.",
+ "installation.issues.mbstring":
+ "Se requiere la extensión MB String
.",
+ "installation.issues.media":
+ "La carpeta /media
no existe o no posee permisos de escritura.",
+ "installation.issues.php": "Asegurese de estar usando PHP 7+
",
+ "installation.issues.server":
+ "Kirby requiere Apache
, Nginx
, Caddy
",
+ "installation.issues.sessions": "La carpeta /site/sessions
no existe o no posee permisos de escritura.",
+
+ "language": "Idioma",
+ "language.code": "Código",
+ "language.convert": "Hacer por defecto",
+ "language.convert.confirm":
+ "
Todas las súbpaginas serán eliminadas también.",
+ "page.delete.confirm.title": "Introduce el título de la página para confirmar",
+ "page.draft.create": "Crear borrador",
+ "page.duplicate.appendix": "Copiar",
+ "page.duplicate.files": "Copiar archivos",
+ "page.duplicate.pages": "Copiar páginas",
+ "page.status": "Estado",
+ "page.status.draft": "Borrador",
+ "page.status.draft.description":
+ "La página está en modo de borrador y sólo es visible para los editores registrados",
+ "page.status.listed": "Pública",
+ "page.status.listed.description": "La página es pública para cualquiera",
+ "page.status.unlisted": "No publicada",
+ "page.status.unlisted.description": "La página sólo es accesible vía URL",
+
+ "pages": "Páginas",
+ "pages.empty": "No hay páginas aún",
+ "pages.status.draft": "Borradores",
+ "pages.status.listed": "Publicado",
+ "pages.status.unlisted": "No publicado",
+
+ "pagination.page": "Página",
+
+ "password": "Contrase\u00f1a",
+ "pixel": "Pixel",
+ "prev": "Anterior",
+ "remove": "Eliminar",
+ "rename": "Renombrar",
+ "replace": "Reemplazar",
+ "retry": "Reintentar",
+ "revert": "Revertir",
+
+ "role": "Rol",
+ "role.admin.description": "El administrador tiene todos los derechos",
+ "role.admin.title": "Administrador",
+ "role.all": "Todos",
+ "role.empty": "No hay usuarios con este rol",
+ "role.description.placeholder": "Sin descripción",
+ "role.nobody.description": "Este es un rol alternativo sin permisos",
+ "role.nobody.title": "Nadie",
+
+ "save": "Guardar",
+ "search": "Buscar",
+
+ "section.required": "Esta sección es requerida",
+
+ "select": "Seleccionar",
+ "settings": "Ajustes",
+ "size": "Tamaño",
+ "slug": "Apéndice URL",
+ "sort": "Ordenar",
+ "title": "Título",
+ "template": "Plantilla",
+ "today": "Hoy",
+
+ "toolbar.button.code": "Código",
+ "toolbar.button.bold": "Negrita",
+ "toolbar.button.email": "Email",
+ "toolbar.button.headings": "Encabezados",
+ "toolbar.button.heading.1": "Encabezado 1",
+ "toolbar.button.heading.2": "Encabezado 2",
+ "toolbar.button.heading.3": "Encabezado 3",
+ "toolbar.button.italic": "Texto en It\u00e1licas",
+ "toolbar.button.file": "Archivo",
+ "toolbar.button.file.select": "Selecciona un archivo",
+ "toolbar.button.file.upload": "Sube un archivo",
+ "toolbar.button.link": "Enlace",
+ "toolbar.button.ol": "Lista en orden",
+ "toolbar.button.ul": "Lista de viñetas",
+
+ "translation.author": "Equipo Kirby",
+ "translation.direction": "ltr",
+ "translation.name": "Español (América Latina)",
+ "translation.locale": "es_419",
+
+ "upload": "Subir",
+ "upload.error.cantMove": "El archivo subido no puede ser movido",
+ "upload.error.cantWrite": "Error al escribir el archivo en el disco",
+ "upload.error.default": "El archivo no pudo ser subido",
+ "upload.error.extension": "Subida de archivo detenida por la extensión",
+ "upload.error.formSize": "El archivo subido excede la directiva MAX_FILE_SIZE que fue especificada en el formulario",
+ "upload.error.iniPostSize": "El archivo subido excede la directiva post_max_size directive en php.ini",
+ "upload.error.iniSize": "El archivo subido excede la directiva upload_max_filesize en php.ini",
+ "upload.error.noFile": "Ningún archivo ha sido subido",
+ "upload.error.noFiles": "Ningún archivo ha sido subido",
+ "upload.error.partial": "El archivo ha sido subido solo parcialmente",
+ "upload.error.tmpDir": "No se encuentra la carpeta temporal",
+ "upload.errors": "Error",
+ "upload.progress": "Subiendo...",
+
+ "url": "Url",
+ "url.placeholder": "https://ejemplo.com",
+
+ "user": "Usuario",
+ "user.blueprint":
+ "Puedes definir secciones adicionales y campos de formulario para este rol de usuario en /site/blueprints/users/{role}.yml",
+ "user.changeEmail": "Cambiar correo electrónico",
+ "user.changeLanguage": "Cambiar idioma",
+ "user.changeName": "Renombrar este usuario",
+ "user.changePassword": "Cambiar la contraseña",
+ "user.changePassword.new": "Nueva contraseña",
+ "user.changePassword.new.confirm": "Confirma la nueva contraseña...",
+ "user.changeRole": "Cambiar rol",
+ "user.changeRole.select": "Selecciona un nuevo rol",
+ "user.create": "Agregar un nuevo usuario",
+ "user.delete": "Eliminar este usuario",
+ "user.delete.confirm":
+ "¿Estás seguro que deseas eliminar
{email}?",
+
+ "users": "Usuarios",
+
+ "version": "Versión",
+
+ "view.account": "Tu cuenta",
+ "view.installation": "Instalaci\u00f3n",
+ "view.settings": "Ajustes",
+ "view.site": "Sitio",
+ "view.users": "Usuarios",
+
+ "welcome": "Bienvenido",
+ "year": "Año"
+}
diff --git a/kirby/i18n/translations/es_ES.json b/kirby/i18n/translations/es_ES.json
new file mode 100755
index 0000000..f73a36a
--- /dev/null
+++ b/kirby/i18n/translations/es_ES.json
@@ -0,0 +1,481 @@
+{
+ "add": "Añadir",
+ "avatar": "Foto de perfil",
+ "back": "Atrás",
+ "cancel": "Cancelar",
+ "change": "Cambiar",
+ "close": "Cerrar",
+ "confirm": "Confirmar",
+ "copy": "Copiar",
+ "create": "Crear",
+
+ "date": "Fecha",
+ "date.select": "Selecciona una fecha",
+
+ "day": "Día",
+ "days.fri": "Vi",
+ "days.mon": "Lu",
+ "days.sat": "Sá",
+ "days.sun": "Do",
+ "days.thu": "Ju",
+ "days.tue": "Ma",
+ "days.wed": "Mi",
+
+ "delete": "Borrar",
+ "dimensions": "Dimensiones",
+ "disabled": "Desabilitado",
+ "discard": "Descartar",
+ "download": "Descargar",
+ "duplicate": "Duplicar",
+ "edit": "Editar",
+
+ "dialog.files.empty": "No se ha seleccionado ningún archivo",
+ "dialog.pages.empty": "No se ha seleccionado ninguna página",
+ "dialog.users.empty": "No se ha seleccionado ningún usuario",
+
+ "email": "Correo electrónico",
+ "email.placeholder": "correo@ejemplo.com",
+
+ "error.access.login": "Ingreso inválido",
+ "error.access.panel": "No estás autorizado para acceder al panel",
+ "error.access.view": "You are not allowed to access this part of the panel",
+
+ "error.avatar.create.fail": "No se pudo subir la foto de perfil.",
+ "error.avatar.delete.fail": "No se pudo borrar la foto de perfil",
+ "error.avatar.dimensions.invalid":
+ "Por favor, mantenga el ancho y la altura de la imagen de perfil debajo de 3000 píxeles",
+ "error.avatar.mime.forbidden":
+ "La imagen del perfil debe ser JPEG o PNG.",
+
+ "error.blueprint.notFound": "El blueprint \"{name}\" no pudo ser cargado",
+
+ "error.email.preset.notFound": "El preset del correo \"{name}\" no pudo ser encontrado",
+
+ "error.field.converter.invalid": "Convertidor \"{converter}\" inválido",
+
+ "error.file.changeName.empty": "The name must not be empty",
+ "error.file.changeName.permission":
+ "No tienes permitido cambiar el nombre de \"{filename}\"",
+ "error.file.duplicate": "Ya existe un archivo con el nombre \"{filename}\"",
+ "error.file.extension.forbidden":
+ "La extensión \"{extension}\" no está permitida",
+ "error.file.extension.missing":
+ "Falta la extensión para \"{filename}\"",
+ "error.file.maxheight": "The height of the image must not exceed {height} pixels",
+ "error.file.maxsize": "The file is too large",
+ "error.file.maxwidth": "The width of the image must not exceed {width} pixels",
+ "error.file.mime.differs":
+ "El archivo cargado debe ser del mismo tipo mime \"{mime}\"",
+ "error.file.mime.forbidden": "Los medios tipo \"{mime}\" no están permitidos",
+ "error.file.mime.invalid": "Invalid mime type: {mime}",
+ "error.file.mime.missing":
+ "El tipo de medio para \"{filename}\" no pudo ser detectado",
+ "error.file.minheight": "The height of the image must be at least {height} pixels",
+ "error.file.minsize": "The file is too small",
+ "error.file.minwidth": "The width of the image must be at least {width} pixels",
+ "error.file.name.missing": "El nombre de archivo no debe estar vacío",
+ "error.file.notFound": "El archivo \"{filename}\" no pudo ser encontrado",
+ "error.file.orientation": "The orientation of the image must be \"{orientation}\"",
+ "error.file.type.forbidden": "No está permitido subir archivos {type}",
+ "error.file.undefined": "El archivo no pudo ser encontrado",
+
+ "error.form.incomplete": "Por favor, corrija todos los errores del formulario…",
+ "error.form.notSaved": "El formulario no pudo ser guardado",
+
+ "error.language.code": "Please enter a valid code for the language",
+ "error.language.duplicate": "The language already exists",
+ "error.language.name": "Please enter a valid name for the language",
+
+ "error.license.format": "Please enter a valid license key",
+ "error.license.email": "Por favor, introduce un correo electrónico válido",
+ "error.license.verification": "The license could not be verified",
+
+ "error.page.changeSlug.permission":
+ "No está permitido cambiar el apéndice de URL para \"{slug}\"",
+ "error.page.changeStatus.incomplete":
+ "La página tiene errores y no puede ser publicada.",
+ "error.page.changeStatus.permission":
+ "El estado de esta página no se puede cambiar",
+ "error.page.changeStatus.toDraft.invalid":
+ "La página \"{slug}\" no se puede convertir a borrador",
+ "error.page.changeTemplate.invalid":
+ "La plantilla para la página \"{slug}\" no se puede cambiar",
+ "error.page.changeTemplate.permission":
+ "No tienes permitido cambiar la plantilla para \"{slug}\"",
+ "error.page.changeTitle.empty": "El título no debe estar vacío.",
+ "error.page.changeTitle.permission":
+ "No tienes permitido cambiar el título por \"{slug}\"",
+ "error.page.create.permission": "No tienes permitido crear \"{slug}\"",
+ "error.page.delete": "La página \"{slug}\" no puede ser eliminada",
+ "error.page.delete.confirm": "Por favor, introduzca el título de la página para confirmar",
+ "error.page.delete.hasChildren":
+ "La página tiene subpáginas y no se puede eliminar",
+ "error.page.delete.permission": "No tienes permiso de eliminar \"{slug}\"",
+ "error.page.draft.duplicate":
+ "Un borrador de página con el apéndice de URL \"{slug}\" ya existe",
+ "error.page.duplicate":
+ "Una página con el apéndice de URL. \"{slug}\" ya existe",
+ "error.page.duplicate.permission": "You are not allowed to duplicate \"{slug}\"",
+ "error.page.notFound": "La página \"{slug}\" no puede ser encontrada",
+ "error.page.num.invalid":
+ "Por favor, introduzca un número válido. Estos no deben ser negativos.",
+ "error.page.slug.invalid": "Por favor ingrese un prefijo de URL válido",
+ "error.page.sort.permission": "La página \"{slug}\" no se puede ordenar",
+ "error.page.status.invalid": "Por favor, establezca un estado de página válido",
+ "error.page.undefined": "La página no se puede encontrar",
+ "error.page.update.permission": "No tienes permitido actualizar \"{slug}\"",
+
+ "error.section.files.max.plural":
+ "No debes agregar más de {max} archivos a la sección \"{section}\"",
+ "error.section.files.max.singular":
+ "No debes agregar más de 1 archivo a la sección \"{section}\"",
+ "error.section.files.min.plural":
+ "La sección \"{section}\" requiere al menos {min} archivos",
+ "error.section.files.min.singular":
+ "La sección \"{section}\" requiere al menos un archivo",
+
+ "error.section.pages.max.plural":
+ "No debe agregar más de {max} páginas a la sección \"{section}\"",
+ "error.section.pages.max.singular":
+ "No debe agregar más de una página a la sección \"{section}\"",
+ "error.section.pages.min.plural":
+ "La sección \"{section}\" requiere al menos {min} páginas",
+ "error.section.pages.min.singular":
+ "La sección \"{section}\" requiere al menos una página",
+
+ "error.section.notLoaded": "La sección \"{name}\" no pudo ser cargada",
+ "error.section.type.invalid": "El sección tipo \"{tipo}\" no es válido",
+
+ "error.site.changeTitle.empty": "El título no debe estar vacío.",
+ "error.site.changeTitle.permission":
+ "No está permitido cambiar el título del sitio",
+ "error.site.update.permission": "No tienes permitido actualizar el sitio",
+
+ "error.template.default.notFound": "La plantilla por defecto no existe",
+
+ "error.user.changeEmail.permission":
+ "No tienes permitido cambiar el correo electrónico para el usuario \"{name}\"",
+ "error.user.changeLanguage.permission":
+ "No tienes permitido cambiar el idioma para el usuario \"{name}\"",
+ "error.user.changeName.permission":
+ "No tienes permitido cambiar el nombre del usuario \"{name}\"",
+ "error.user.changePassword.permission":
+ "No tienes permitido cambiar la contraseña del usuario \"{name}\"",
+ "error.user.changeRole.lastAdmin":
+ "El rol para el último administrador no puede ser cambiado",
+ "error.user.changeRole.permission":
+ "No tienes permitido cambiar el rol del usuario \"{name}\"",
+ "error.user.changeRole.toAdmin":
+ "You are not allowed to promote someone to the admin role",
+ "error.user.create.permission": "No tienes permiso para crear este usuario",
+ "error.user.delete": "El usuario \"{name}\" no puede ser eliminado",
+ "error.user.delete.lastAdmin": "El último administrador no puede ser eliminado",
+ "error.user.delete.lastUser": "El último usuario no puede ser eliminado",
+ "error.user.delete.permission":
+ "No tienes permitido eliminar el usuario \"{name}\"",
+ "error.user.duplicate":
+ "Un usuario con la dirección de correo electrónico \"{email}\" ya existe",
+ "error.user.email.invalid": "Por favor, introduce una dirección de correo electrónico válida",
+ "error.user.language.invalid": "Por favor ingrese un idioma válido",
+ "error.user.notFound": "El usuario \"{name}\" no pudo ser encontrado",
+ "error.user.password.invalid":
+ "Por favor introduce una contraseña válida. Las contraseñas deben tener al menos 8 caracteres de largo.",
+ "error.user.password.notSame": "Las contraseñas no coinciden",
+ "error.user.password.undefined": "El usuario no tiene contraseña",
+ "error.user.role.invalid": "Por favor ingrese un rol válido",
+ "error.user.update.permission":
+ "No tienes permitido actualizar al usuario \"{name}\"",
+
+ "error.validation.accepted": "Por favor, confirma",
+ "error.validation.alpha": "Por favor solo ingresa caracteres entre a-z",
+ "error.validation.alphanum":
+ "Por favor solo ingrese caracteres entre a-z o numerales 0-9",
+ "error.validation.between":
+ "Por favor, introduzca un valor entre \"{min}\" y \"{max}\"",
+ "error.validation.boolean": "Por favor confirme o rechace",
+ "error.validation.contains":
+ "Por favor ingrese un valor que contenga \"{needle}\"",
+ "error.validation.date": "Por favor introduzca una fecha valida",
+ "error.validation.date.after": "Please enter a date after {date}",
+ "error.validation.date.before": "Please enter a date before {date}",
+ "error.validation.date.between": "Please enter a date between {min} and {max}",
+ "error.validation.denied": "Por favor, rechace",
+ "error.validation.different": "El valor no debe ser \"{other}\"",
+ "error.validation.email": "Por favor, introduce un correo electrónico válido",
+ "error.validation.endswith": "El valor debe terminar con \"{end}\"",
+ "error.validation.filename": "Por favor ingrese un nombre de archivo válido",
+ "error.validation.in": "Por favor ingrese uno de los siguientes: ({in})",
+ "error.validation.integer": "Por favor, introduce un numero integro válido",
+ "error.validation.ip": "Por favor ingrese una dirección IP válida",
+ "error.validation.less": "Por favor, introduzca un valor inferior a {max}",
+ "error.validation.match": "El valor no coincide con el patrón esperado",
+ "error.validation.max": "Por favor, introduzca un valor igual o inferior a {max}",
+ "error.validation.maxlength":
+ "Por favor, introduzca un valor más corto. (max. {max} caracteres)",
+ "error.validation.maxwords": "Por favor ingrese no más de {max} palabra(s)",
+ "error.validation.min": "Por favor, introduzca un valor igual o mayor a {min}",
+ "error.validation.minlength":
+ "Por favor, introduzca un valor más largo. (min. {min} caracteres)",
+ "error.validation.minwords": "Por favor ingrese al menos {min} palabra(s)",
+ "error.validation.more": "Por favor, introduzca un valor mayor a {min}",
+ "error.validation.notcontains":
+ "Por favor ingrese un valor que no contenga \"{needle}\"",
+ "error.validation.notin":
+ "Por favor, no ingrese ninguno de los siguientes: ({notIn})",
+ "error.validation.option": "Por favor seleccione una opción válida",
+ "error.validation.num": "Por favor ingrese un número valido",
+ "error.validation.required": "Por favor ingrese algo",
+ "error.validation.same": "Por favor escribe \"{other}\"",
+ "error.validation.size": "El tamaño del valor debe ser \"{size}\"",
+ "error.validation.startswith": "El valor debe comenzar con \"{start}\"",
+ "error.validation.time": "Por favor ingrese una hora válida",
+ "error.validation.url": "Por favor introduzca un URL válido",
+
+ "field.required": "The field is required",
+ "field.files.empty": "Aún no hay archivos seleccionados",
+ "field.pages.empty": "Aún no hay páginas seleccionadas",
+ "field.structure.delete.confirm": "¿Realmente quieres eliminar esta fila?",
+ "field.structure.empty": "Aún no hay entradas",
+ "field.users.empty": "Aún no hay usuarios seleccionados",
+
+ "file.delete.confirm":
+ "¿Realmente quieres eliminar
{filename}?",
+
+ "files": "Archivos",
+ "files.empty": "Aún no hay archivos",
+
+ "hour": "Hora",
+ "insert": "Insertar",
+ "install": "Instalar",
+
+ "installation": "Instalación",
+ "installation.completed": "El panel ha sido instalado",
+ "installation.disabled": "El instalador del panel está deshabilitado en servidores públicos por defecto. Ejecute el instalador en una máquina local o habilítelo con la opción panel.install
.",
+ "installation.issues.accounts":
+ "La carpeta /site/accounts
no existe o no se puede escribir",
+ "installation.issues.content":
+ "La carpeta /content
no existe o no se puede escribir",
+ "installation.issues.curl": "La extensión CURL
es requerida",
+ "installation.issues.headline": "No se pudo instalar el panel",
+ "installation.issues.mbstring":
+ "La extension MB String
es requerida",
+ "installation.issues.media":
+ "La carpeta /media
no existe o no se puede escribir",
+ "installation.issues.php": "Asegúrate de usar PHP 7+
",
+ "installation.issues.server":
+ "Kirby requiere Apache
, Nginx
o Caddy
",
+ "installation.issues.sessions": "La carpeta /site/sessions
no existe o no se puede escribir",
+
+ "language": "Idioma",
+ "language.code": "Código",
+ "language.convert": "Hacer por defecto",
+ "language.convert.confirm":
+ "
Si {name} tiene contenido sin traducir, ya no habrá un respaldo válido y algunas partes de su sitio podrían estar vacías.
", + "language.create": "Añadir un nuevo idioma", + "language.delete.confirm": + "¿De verdad quieres eliminar el idioma {name} incluyendo todas las traducciones? ¡Esto no se puede deshacer!", + "language.deleted": "El idioma ha sido eliminado", + "language.direction": "Leyendo dirección", + "language.direction.ltr": "De izquierda a derecha", + "language.direction.rtl": "De derecha a izquierda", + "language.locale": "PHP locale string", + "language.locale.warning": "You are using a custom locale set up. Please modify it in the language file in /site/languages", + "language.name": "Nombre", + "language.updated": "El idioma ha sido actualizado", + + "languages": "Idiomas", + "languages.default": "Idioma predeterminado", + "languages.empty": "Todavía no hay idiomas", + "languages.secondary": "Idiomas secundarios", + "languages.secondary.empty": "Todavía no hay idiomas secundarios", + + "license": "Licencia", + "license.buy": "Comprar una licencia", + "license.register": "Registro", + "license.register.help": + "Recibió su código de licencia después de la compra por correo electrónico. Por favor copie y pegue para registrarse.", + "license.register.label": "Por favor ingrese su código de licencia", + "license.register.success": "Gracias por apoyar a Kirby", + "license.unregistered": "Esta es una demo no registrada de Kirby", + + "link": "Enlace", + "link.text": "Texto del enlace", + + "loading": "Cargando", + + "lock.unsaved": "Unsaved changes", + "lock.unsaved.empty": "There are no more unsaved changes", + "lock.isLocked": "Unsaved changes by {email}", + "lock.file.isLocked": "The file is currently being edited by {email} and cannot be changed.", + "lock.page.isLocked": "The page is currently being edited by {email} and cannot be changed.", + "lock.unlock": "Unlock", + "lock.isUnlocked": "Your unsaved changes have been overwritten by another user. You can download your changes to merge them manually.", + + "login": "Iniciar sesión", + "login.remember": "Mantener sesión iniciada", + + "logout": "Cerrar sesión", + + "menu": "Menu", + "meridiem": "AM/PM", + "mime": "Tipos de medios", + "minutes": "Minutos", + + "month": "Mes", + "months.april": "Abril", + "months.august": "Agosto", + "months.december": "Diciembre", + "months.february": "Febrero", + "months.january": "Enero", + "months.july": "Julio", + "months.june": "Junio", + "months.march": "Marzo", + "months.may": "Mayo", + "months.november": "Noviembre", + "months.october": "Octubre", + "months.september": "Septiembre", + + "more": "Más", + "name": "Nombre", + "next": "Siguiente", + "off": "off", + "on": "on", + "open": "Abrir", + "options": "Opciones", + + "orientation": "Orientación", + "orientation.landscape": "Paisaje", + "orientation.portrait": "Retrato", + "orientation.square": "Cuadrado", + + "page.changeSlug": "Cambiar URL", + "page.changeSlug.fromTitle": "Crear en base al título", + "page.changeStatus": "Cambiar estado", + "page.changeStatus.position": "Por favor seleccione una posición", + "page.changeStatus.select": "Seleccione un nuevo estado", + "page.changeTemplate": "Cambiar plantilla", + "page.delete.confirm": + "¿Realmente quieres eliminar {title}?", + "page.delete.confirm.subpages": + "Esta página tiene subpáginas.panel.install
فعال کنید.",
+ "installation.issues.accounts":
+ "پوشه /site/accounts
موجود نیست یا قابل نوشتن نیست.",
+ "installation.issues.content":
+ "پوشه /content
موجود نیست یا قابل نوشتن نیست",
+ "installation.issues.curl": "افزونه CURL
مورد نیاز است",
+ "installation.issues.headline": "نصب پانل کاربری ممکن نیست",
+ "installation.issues.mbstring":
+ "افزونه MB String
مورد نیاز است",
+ "installation.issues.media":
+ "پوشه /media
موجود نیست یا قابل نوشتن نیست",
+ "installation.issues.php": "لطفا از پیاچپی 7 یا بالاتر استفاده کنید",
+ "installation.issues.server":
+ "کربی نیاز به Apache
، Nginx
یا Caddy
دارد",
+ "installation.issues.sessions": "پوشه /site/sessions
وجود ندارد یا قابل نوشتن نیست",
+
+ "language": "\u0632\u0628\u0627\u0646",
+ "language.code": "کد",
+ "language.convert": "پیشفرض شود",
+ "language.convert.confirm":
+ "آیا واقعا میخواهید {name} را به زبان پیشفرض تبدیل کنید؟ این عمل برگشت ناپذیر است.
اگر {name} دارای محتوای غیر ترجمه شده باشد، جایگزین معتبر دیگری نخواهد بود و ممکن است بخشهایی از سایت شما خالی باشد.
", + "language.create": "افزودن زبان جدید", + "language.delete.confirm": + "آیا واقعا میخواهید زبان {name} را به همراه تمام ترجمهها حذف کنید؟ این عمل قابل بازگشت نخواهد بود!", + "language.deleted": "زبان مورد نظر حذف شد", + "language.direction": "rtl", + "language.direction.ltr": "چپ به راست", + "language.direction.rtl": "راست به چپ", + "language.locale": "PHP locale string", + "language.locale.warning": "You are using a custom locale set up. Please modify it in the language file in /site/languages", + "language.name": "پارسی", + "language.updated": "زبان به روز شد", + + "languages": "زبانها", + "languages.default": "زبان پیشفرض", + "languages.empty": "هنوز هیچ زبانی موجود نیست", + "languages.secondary": "زبانهای ثانویه", + "languages.secondary.empty": "هنوز هیچ زبان ثانویهای موجود نیست", + + "license": "\u0645\u062c\u0648\u0632", + "license.buy": "خرید مجوز", + "license.register": "ثبت", + "license.register.help": + "پس از خرید از طریق ایمیل، کد مجوز خود را دریافت کردید. لطفا برای ثبتنام آن را کپی و اینجا پیست کنید.", + "license.register.label": "لطفا کد مجوز خود را وارد کنید", + "license.register.success": "با تشکر از شما برای حمایت از کربی", + "license.unregistered": "این یک نسخه آزمایشی ثبت نشده از کربی است", + + "link": "\u067e\u06cc\u0648\u0646\u062f", + "link.text": "\u0645\u062a\u0646 \u067e\u06cc\u0648\u0646\u062f", + + "loading": "بارگزاری", + + "lock.unsaved": "Unsaved changes", + "lock.unsaved.empty": "There are no more unsaved changes", + "lock.isLocked": "Unsaved changes by {email}", + "lock.file.isLocked": "The file is currently being edited by {email} and cannot be changed.", + "lock.page.isLocked": "The page is currently being edited by {email} and cannot be changed.", + "lock.unlock": "Unlock", + "lock.isUnlocked": "Your unsaved changes have been overwritten by another user. You can download your changes to merge them manually.", + + "login": "ورود", + "login.remember": "مرا به خاطر بسپار", + + "logout": "خروج", + + "menu": "منو", + "meridiem": "ق.ظ/ب.ظ", + "mime": "نوع رسانه", + "minutes": "دقیقه", + + "month": "ماه", + "months.april": "\u0622\u0648\u0631\u06cc\u0644", + "months.august": "\u0627\u0648\u062a", + "months.december": "\u062f\u0633\u0627\u0645\u0628\u0631", + "months.february": "\u0641\u0648\u0631\u06cc\u0647", + "months.january": "\u0698\u0627\u0646\u0648\u06cc\u0647", + "months.july": "\u0698\u0648\u0626\u06cc\u0647", + "months.june": "\u0698\u0648\u0626\u0646", + "months.march": "\u0645\u0627\u0631\u0633", + "months.may": "\u0645\u06cc", + "months.november": "\u0646\u0648\u0627\u0645\u0628\u0631", + "months.october": "\u0627\u06a9\u062a\u0628\u0631", + "months.september": "\u0633\u067e\u062a\u0627\u0645\u0628\u0631", + + "more": "بیشتر", + "name": "نام", + "next": "بعدی", + "off": "off", + "on": "on", + "open": "بازکردن", + "options": "گزینهها", + + "orientation": "جهت", + "orientation.landscape": "افقی", + "orientation.portrait": "عمودی", + "orientation.square": "مربع", + + "page.changeSlug": "تغییر Url صفحه", + "page.changeSlug.fromTitle": "\u0627\u06cc\u062c\u0627\u062f \u0627\u0632 \u0631\u0648\u06cc \u0639\u0646\u0648\u0627\u0646", + "page.changeStatus": "تغییر وضعیت", + "page.changeStatus.position": "لطفا یک موقعیت را انتخاب کنید", + "page.changeStatus.select": "یک وضعیت جدید را انتخاب کنید", + "page.changeTemplate": "تغییر قالب", + "page.delete.confirm": + "صفحه {title} حذف شود؟", + "page.delete.confirm.subpages": + "این صفحه دارای زیرصفحه است.panel.install
-optiolla.",
+ "installation.issues.accounts":
+ "/site/accounts
-kansio ei ole olemassa tai siihen ei voi kirjoittaa",
+ "installation.issues.content":
+ "/content
-kansio ei ole olemassa tai siihen ei voi kirjoittaa",
+ "installation.issues.curl": "CURL
-laajennos on pakollinen",
+ "installation.issues.headline": "Paneelia ei voida asentaa",
+ "installation.issues.mbstring":
+ "MB String
-laajennos on pakollinen",
+ "installation.issues.media":
+ "/media
-kansio ei ole olemassa tai siihen ei voi kirjoittaa",
+ "installation.issues.php": "Varmista että PHP 7+
on käytössä",
+ "installation.issues.server":
+ "Kirby tarvitsee jonkun seuraavista: Apache
, Nginx
tai Caddy
",
+ "installation.issues.sessions": "/site/sessions
-kansio ei ole olemassa tai siihen ei voi kirjoittaa",
+
+ "language": "Kieli",
+ "language.code": "Tunniste",
+ "language.convert": "Muuta oletukseksi",
+ "language.convert.confirm":
+ "Haluatko varmasti muuttaa kielen {name} oletuskieleksi? Tätä muutosta ei voi peruuttaa.
Jos{name} sisältää kääntämättömiä kohtia, varakäännöstä ei enää ole näille kohdille ja sivustosi saattaa olla osittain tyhjä.
", + "language.create": "Lisää uusi kieli", + "language.delete.confirm": + "Haluatko varmasti poistaa kielen {name}, mukaanlukien kaikki käännökset? Tätä toimintoa ei voi peruuttaa!", + "language.deleted": "Kieli on poistettu", + "language.direction": "Lukusuunta", + "language.direction.ltr": "Vasemmalta oikealle", + "language.direction.rtl": "Oikealta vasemmalle", + "language.locale": "PHP-lokaalin tunniste", + "language.locale.warning": "You are using a custom locale set up. Please modify it in the language file in /site/languages", + "language.name": "Nimi", + "language.updated": "Kieli on päivitetty", + + "languages": "Kielet", + "languages.default": "Oletuskieli", + "languages.empty": "Kieliä ei ole vielä määritetty", + "languages.secondary": "Toissijaiset kielet", + "languages.secondary.empty": "Toissijaisia kieliä ei ole vielä määritetty", + + "license": "Lisenssi", + "license.buy": "Osta lisenssi", + "license.register": "Rekisteröi", + "license.register.help": + "Lisenssiavain on lähetetty oston jälkeen sähköpostiisi. Kopioi ja liitä avain tähän.", + "license.register.label": "Anna lisenssiavain", + "license.register.success": "Kiitos kun tuet Kirbyä", + "license.unregistered": "Tämä on rekisteröimätön demo Kirbystä", + + "link": "Linkki", + "link.text": "Linkin teksti", + + "loading": "Ladataan", + + "lock.unsaved": "Tallentamattomia muutoksia", + "lock.unsaved.empty": "There are no more unsaved changes", + "lock.isLocked": "Käyttäjällä {email} on tallentamattomia muutoksia", + "lock.file.isLocked": "The file is currently being edited by {email} and cannot be changed.", + "lock.page.isLocked": "The page is currently being edited by {email} and cannot be changed.", + "lock.unlock": "Vapauta", + "lock.isUnlocked": "Your unsaved changes have been overwritten by another user. You can download your changes to merge them manually.", + + "login": "Kirjaudu", + "login.remember": "Pidä minut kirjautuneena", + + "logout": "Kirjaudu ulos", + + "menu": "Valikko", + "meridiem": "am/pm", + "mime": "Median tyyppi", + "minutes": "Minuutit", + + "month": "Kuukausi", + "months.april": "Huhtikuu", + "months.august": "Elokuu", + "months.december": "Joulukuu", + "months.february": "Helmikuu", + "months.january": "Tammikuu", + "months.july": "Hein\u00e4kuu", + "months.june": "Kes\u00e4kuu", + "months.march": "Maaliskuu", + "months.may": "Toukokuu", + "months.november": "Marraskuu", + "months.october": "Lokakuu", + "months.september": "Syyskuu", + + "more": "Lisää", + "name": "Nimi", + "next": "Seuraava", + "off": "off", + "on": "on", + "open": "Avaa", + "options": "Asetukset", + + "orientation": "Suunta", + "orientation.landscape": "Vaakasuuntainen", + "orientation.portrait": "Pystysuuntainen", + "orientation.square": "Neliskulmainen", + + "page.changeSlug": "Vaihda URL-osoite", + "page.changeSlug.fromTitle": "Luo nimen perusteella", + "page.changeStatus": "Muuta tilaa", + "page.changeStatus.position": "Valitse järjestyspaikka", + "page.changeStatus.select": "Valitse uusi tila", + "page.changeTemplate": "Vaihda sivupohja", + "page.delete.confirm": + "Haluatko varmasti poistaa sivun {title}?", + "page.delete.confirm.subpages": + "Tällä sivulla on alasivuja.panel.install
.",
+ "installation.issues.accounts":
+ "Le dossier /site/accounts
n’existe pas ou n’est pas accessible en écriture",
+ "installation.issues.content":
+ "Le dossier /content
n’existe pas ou n’est pas accessible en écriture",
+ "installation.issues.curl": "L’extension CURL
est requise",
+ "installation.issues.headline": "Le Panel ne peut être installé",
+ "installation.issues.mbstring":
+ "L’extension MB String
est requise",
+ "installation.issues.media":
+ "Le dossier /media
n’existe pas ou n’est pas accessible en écriture",
+ "installation.issues.php": "Veuillez utiliser PHP 7+
",
+ "installation.issues.server":
+ "Kirby requiert Apache
, Nginx
ou Caddy
",
+ "installation.issues.sessions": "Le dossier /site/sessions
n’existe pas ou n’est pas accessible en écriture",
+
+ "language": "Langue",
+ "language.code": "Code",
+ "language.convert": "Choisir comme langue par défaut",
+ "language.convert.confirm":
+ "Souhaitez-vous vraiment convertir {name} vers la langue par défaut ? Cette action ne peut pas être annulée.
Si {name} a un contenu non traduit, il n’y aura plus de solution de secours possible et certaines parties de votre site pourraient être vides.
", + "language.create": "Ajouter une nouvelle langue", + "language.delete.confirm": + "Voulez-vous vraiment supprimer la langue {name}, ainsi que toutes ses traductions ? Cette action ne peut être annulée !", + "language.deleted": "La langue a été supprimée", + "language.direction": "Sens de lecture", + "language.direction.ltr": "De gauche à droite", + "language.direction.rtl": "De droite à gauche", + "language.locale": "Locales PHP", + "language.locale.warning": "Vous utilisez une Locale PHP personnalisée. Veuillez la modifier dans le fichier de langue situé dans /site/languages", + "language.name": "Nom", + "language.updated": "La langue a été mise à jour", + + "languages": "Langages", + "languages.default": "Langue par défaut", + "languages.empty": "Il n’y a pas encore de langues", + "languages.secondary": "Langues secondaires", + "languages.secondary.empty": "Il n’y a pas encore de langues secondaires", + + "license": "Licence", + "license.buy": "Acheter une licence", + "license.register": "S’enregistrer", + "license.register.help": + "Vous avez reçu votre numéro de licence par courriel après l'achat. Veuillez le copier et le coller ici pour l'enregistrer.", + "license.register.label": "Veuillez saisir votre numéro de licence", + "license.register.success": "Merci pour votre soutien à Kirby", + "license.unregistered": "Ceci est une démo non enregistrée de Kirby", + + "link": "Lien", + "link.text": "Texte du lien", + + "loading": "Chargement", + + "lock.unsaved": "Modifications non enregistrées", + "lock.unsaved.empty": "Il n’y a plus de modifications non enregistrées", + "lock.isLocked": "Modifications non enregistrées par {email}", + "lock.file.isLocked": "Le fichier est actuellement édité par {email} et ne peut être modifié.", + "lock.page.isLocked": "La page est actuellement éditée par {email} et ne peut être modifiée.", + "lock.unlock": "Déverrouiller", + "lock.isUnlocked": "Vos modifications non enregistrées ont été écrasées pas un autre utilisateur. Vous pouvez télécharger vos modifications pour les fusionner manuellement.", + + "login": "Se connecter", + "login.remember": "Rester connecté", + + "logout": "Se déconnecter", + + "menu": "Menu", + "meridiem": "AM/PM", + "mime": "Type de médias", + "minutes": "Minutes", + + "month": "Mois", + "months.april": "Avril", + "months.august": "Août", + "months.december": "Décembre", + "months.february": "Février", + "months.january": "Janvier", + "months.july": "Juillet", + "months.june": "Juin", + "months.march": "Mars", + "months.may": "Mai", + "months.november": "Novembre", + "months.october": "Octobre", + "months.september": "Septembre", + + "more": "Plus", + "name": "Nom", + "next": "Suivant", + "off": "off", + "on": "on", + "open": "Ouvrir", + "options": "Options", + + "orientation": "Orientation", + "orientation.landscape": "Paysage", + "orientation.portrait": "Portrait", + "orientation.square": "Carré", + + "page.changeSlug": "Modifier l’URL", + "page.changeSlug.fromTitle": "Créer à partir du titre", + "page.changeStatus": "Changer le statut", + "page.changeStatus.position": "Veuillez sélectionner une position", + "page.changeStatus.select": "Sélectionner un nouveau statut", + "page.changeTemplate": "Changer de modèle", + "page.delete.confirm": + "Voulez-vous vraiment supprimer {title} ?", + "page.delete.confirm.subpages": + "Cette page contient des sous-pages.panel.install
opcióval.",
+ "installation.issues.accounts":
+ "A /site/accounts
mappa nem létezik, vagy nem írható",
+ "installation.issues.content":
+ "A /content
mappa nem létezik vagy nem írható",
+ "installation.issues.curl": "A CURL
bővítmény engedélyezése szükséges",
+ "installation.issues.headline": "A panel telepítése sikertelen",
+ "installation.issues.mbstring":
+ "Az MB String
bővítmény engedélyezése szükséges",
+ "installation.issues.media":
+ "A /media
mappa nem létezik vagy nem írható",
+ "installation.issues.php": "Bizonyosodj meg róla, hogy az általad használt PHP-verzió PHP 7+
",
+ "installation.issues.server":
+ "A Kirby az alábbi szervereken futtatható: Apache
, Nginx
vagy Caddy
",
+ "installation.issues.sessions": "A /site/sessions
könyvtár nem létezik vagy nem írható",
+
+ "language": "Nyelv",
+ "language.code": "Kód",
+ "language.convert": "Alapértelmezettnek jelölés",
+ "language.convert.confirm":
+ "Tényleg az alaőértelmezett nyelvre szeretnéd konvertálni ezt: {name}? Ez a művelet nem vonható vissza.
Ha{name} olyat is tartalmaz, amelynek nincs megfelelő fordítása, a honlapod egyes részei az új alapértelmezett nyelv hiányosságai miatt üresek maradhatnak.
", + "language.create": "Új nyelv hozzáadása", + "language.delete.confirm": + "Tényleg törölni szeretnéd a(z) {name} nyelvet, annak minden fordításával együtt? Ez a művelet nem vonható vissza!", + "language.deleted": "A nyelv törölve lett", + "language.direction": "Olvasási irány", + "language.direction.ltr": "Balról jobbra", + "language.direction.rtl": "Jobbról balra", + "language.locale": "PHP locale sztring", + "language.locale.warning": "You are using a custom locale set up. Please modify it in the language file in /site/languages", + "language.name": "Név", + "language.updated": "A nyelv frissítve lett", + + "languages": "Nyelvek", + "languages.default": "Alapértelmezett nyelv", + "languages.empty": "Nincsnek még nyelvek", + "languages.secondary": "Másodlagos nyelvek", + "languages.secondary.empty": "Nincsnek még másodlagos nyelvek", + + "license": "Kirby licenc", + "license.buy": "Licenc vásárlása", + "license.register": "Regisztráció", + "license.register.help": + "A vásárlás után emailben küldjük el a licenc-kódot. Regisztrációhoz másold ide a kapott kódot.", + "license.register.label": "Kérlek írd be a licenc-kódot", + "license.register.success": "Köszönjük, hogy támogatod a Kirby-t", + "license.unregistered": "Jelenleg a Kirby nem regisztrált próbaverzióját használod", + + "link": "Link", + "link.text": "Link szövege", + + "loading": "Betöltés", + + "lock.unsaved": "Nem mentett változások", + "lock.unsaved.empty": "There are no more unsaved changes", + "lock.isLocked": "Nem mentett {email} változások", + "lock.file.isLocked": "A fájlt jelenleg {email} szerkeszti és nem módosítható.", + "lock.page.isLocked": "Az oldalt jelenleg {email} szerkeszti és nem módosítható.", + "lock.unlock": "Kinyit", + "lock.isUnlocked": "A nem mentett módosításokat egy másik felhasználó felülírta. A módosításokat manuálisan egyesítheted.", + + "login": "Bejelentkezés", + "login.remember": "Maradjak bejelentkezve", + + "logout": "Kijelentkezés", + + "menu": "Menü", + "meridiem": "DE/DU", + "mime": "Média-típus", + "minutes": "Perc", + + "month": "Hónap", + "months.april": "\u00e1prilis", + "months.august": "augusztus", + "months.december": "december", + "months.february": "febru\u00e1r", + "months.january": "janu\u00e1r", + "months.july": "j\u00falius", + "months.june": "j\u00fanius", + "months.march": "m\u00e1rcius", + "months.may": "m\u00e1jus", + "months.november": "november", + "months.october": "okt\u00f3ber", + "months.september": "szeptember", + + "more": "Több", + "name": "Név", + "next": "Következő", + "off": "ki", + "on": "be", + "open": "Megnyitás", + "options": "Beállítások", + + "orientation": "Tájolás", + "orientation.landscape": "Fekvő", + "orientation.portrait": "Álló", + "orientation.square": "Négyzetes", + + "page.changeSlug": "URL v\u00e1ltoztat\u00e1sa", + "page.changeSlug.fromTitle": "L\u00e9trehoz\u00e1s c\u00edmb\u0151l", + "page.changeStatus": "Állapot módosítása", + "page.changeStatus.position": "Kérlek válaszd ki a pozíciót", + "page.changeStatus.select": "Új állapot kiválasztása", + "page.changeTemplate": "Sablon módosítása", + "page.delete.confirm": + "Biztos vagy benne, hogy törlöd az alábbi oldalt: {title}?", + "page.delete.confirm.subpages": + "Ehhez az oldalhoz aloldalak tartoznak.panel.install
untuk menjalankan di server saat ini.",
+ "installation.issues.accounts":
+ "Folder /site/accounts
tidak ada atau tidak dapat ditulis",
+ "installation.issues.content":
+ "Folder /content
tidak ada atau tidak dapat ditulis",
+ "installation.issues.curl": "Ekstensi CURL
diperlukan",
+ "installation.issues.headline": "Panel tidak dapat dipasang",
+ "installation.issues.mbstring":
+ "Ekstensi MB String
diperlukan",
+ "installation.issues.media":
+ "Folder /media
tidak ada atau tidak dapat ditulis",
+ "installation.issues.php": "Pastikan Anda menggunakan PHP 7+
",
+ "installation.issues.server":
+ "Kirby memerlukan Apache
, Nginx
, atau Caddy
",
+ "installation.issues.sessions": "Folder /site/sessions
tidak ada atau tidak dapat ditulis",
+
+ "language": "Bahasa",
+ "language.code": "Kode",
+ "language.convert": "Atur sebagai bawaan",
+ "language.convert.confirm":
+ "Anda yakin mengubah {name} menjadi bahasa bawaan? Ini tidak dapat dibatalkan.
Jika {name} memiliki konten yang tidak diterjemahkan, tidak akan ada pengganti yang valid dan dapat menyebabkan beberapa bagian dari situs Anda menjadi kosong.
", + "language.create": "Tambah bahasa baru", + "language.delete.confirm": + "Anda yakin menghapus bahasa {name} termasuk semua terjemahannya? Ini tidak dapat dibatalkan!", + "language.deleted": "Bahasa sudah dihapus", + "language.direction": "Arah baca", + "language.direction.ltr": "Kiri ke kanan", + "language.direction.rtl": "Kanan ke kiri", + "language.locale": "String \"PHP locale\"", + "language.locale.warning": "Anda menggunakan pengaturan lokal ubah suaian. Ubah di berkas bahasa di /site/languages", + "language.name": "Nama", + "language.updated": "Bahasa sudah diperbaharui", + + "languages": "Bahasa", + "languages.default": "Bahasa bawaan", + "languages.empty": "Belum ada bahasa", + "languages.secondary": "Bahasa sekunder", + "languages.secondary.empty": "Belum ada bahasa sekunder", + + "license": "Lisensi Kirby", + "license.buy": "Beli lisensi", + "license.register": "Daftar", + "license.register.help": + "Anda menerima kode lisensi via surel setelah pembelian. Salin dan tempel kode tersebut untuk mendaftarkan.", + "license.register.label": "Masukkan kode lisensi Anda", + "license.register.success": "Terima kasih atas dukungan untuk Kirby", + "license.unregistered": "Ini adalah demo tidak diregistrasi dari Kirby", + + "link": "Tautan", + "link.text": "Teks tautan", + + "loading": "Memuat", + + "lock.unsaved": "Perubahan belum tersimpan", + "lock.unsaved.empty": "Tidak ada lagi perubahan belum tersimpan", + "lock.isLocked": "Perubahan belum tersimpan oleh {email}", + "lock.file.isLocked": "Berkas sedang disunting oleh {email} dan tidak dapat diubah.", + "lock.page.isLocked": "Halaman sedang disunting oleh {email} dan tidak dapat diubah.", + "lock.unlock": "Buka kunci", + "lock.isUnlocked": "Perubahan Anda yang belum tersimpan telah terubah oleh pengguna lain. Anda dapat mengunduh perubahan Anda untuk menggabungkannya manual.", + + "login": "Masuk", + "login.remember": "Biarkan tetap masuk", + + "logout": "Keluar", + + "menu": "Menu", + "meridiem": "AM/PM", + "mime": "Tipe Media", + "minutes": "Menit", + + "month": "Bulan", + "months.april": "April", + "months.august": "Agustus", + "months.december": "Desember", + "months.february": "Februari", + "months.january": "Januari", + "months.july": "Juli", + "months.june": "Juni", + "months.march": "Maret", + "months.may": "Mei", + "months.november": "November", + "months.october": "Oktober", + "months.september": "September", + + "more": "Lebih lanjut", + "name": "Nama", + "next": "Selanjutnya", + "off": "mati", + "on": "hidup", + "open": "Buka", + "options": "Opsi", + + "orientation": "Orientasi", + "orientation.landscape": "Rebah", + "orientation.portrait": "Tegak", + "orientation.square": "Persegi", + + "page.changeSlug": "Ubah URL", + "page.changeSlug.fromTitle": "Buat dari judul", + "page.changeStatus": "Ubah status", + "page.changeStatus.position": "Pilih posisi", + "page.changeStatus.select": "Pilih status baru", + "page.changeTemplate": "Ubah templat", + "page.delete.confirm": + "Anda yakin menghapus {title}?", + "page.delete.confirm.subpages": + "Halaman ini memiliki sub-halaman.panel.install
.",
+ "installation.issues.accounts":
+ "/site/accounts
non esiste o non dispone dei permessi di scrittura",
+ "installation.issues.content":
+ "La cartella /content
non esiste o non dispone dei permessi di scrittura",
+ "installation.issues.curl": "È necessaria l'estensione CURL
",
+ "installation.issues.headline": "Il pannello non può esser installato",
+ "installation.issues.mbstring":
+ "È necessaria l'estensione MB String
",
+ "installation.issues.media":
+ "La cartella /media
non esiste o non dispone dei permessi di scrittura",
+ "installation.issues.php": "Assicurati di utilizzare PHP 7.1+
",
+ "installation.issues.server":
+ "Kirby necessita di Apache
, Nginx
o Caddy
",
+ "installation.issues.sessions": "La cartella /site/sessions
non esiste o non dispone dei permessi di scrittura",
+
+ "language": "Lingua",
+ "language.code": "Codice",
+ "language.convert": "Imposta come predefinito",
+ "language.convert.confirm":
+ "Sei sicuro di voler convertire {name} nella lingua predefinita? Questa operazione non può essere annullata.
Se {name} non contiene tutte le traduzioni, non ci sarà più una versione alternativa valida e parti del sito potrebbero rimanere vuote.
", + "language.create": "Aggiungi una nuova lingua", + "language.delete.confirm": + "Sei sicuro di voler eliminare la lingua {name} con tutte le traduzioni? Non sarà possibile annullare!", + "language.deleted": "La lingua è stata eliminata", + "language.direction": "Direzione di lettura", + "language.direction.ltr": "Sinistra a destra", + "language.direction.rtl": "Destra a sinistra", + "language.locale": "Stringa \"PHP locale\"", + "language.locale.warning": "Stai usando una impostazione personalizzata per il locale. Modificalo nel file della lingua situato in /site/languages", + "language.name": "Nome", + "language.updated": "La lingua è stata aggiornata", + + "languages": "Lingue", + "languages.default": "Lingua di default", + "languages.empty": "Non ci sono lingue impostate", + "languages.secondary": "Lingue secondarie", + "languages.secondary.empty": "Non ci sono lingue secondarie impostate", + + "license": "Licenza di Kirby", + "license.buy": "Acquista una licenza", + "license.register": "Registra", + "license.register.help": + "Hai ricevuto il codice di licenza tramite email dopo l'acquisto. Per favore inseriscilo per registrare Kirby.", + "license.register.label": "Inserisci il codice di licenza", + "license.register.success": "Ti ringraziamo per aver supportato Kirby", + "license.unregistered": "Questa è una versione demo di Kirby non registrata", + + "link": "Link", + "link.text": "Testo del link", + + "loading": "Caricamento", + + "lock.unsaved": "Modifiche non salvate", + "lock.unsaved.empty": "There are no more unsaved changes", + "lock.isLocked": "Modifiche non salvate di {email}", + "lock.file.isLocked": "Il file viene attualmente modificato da {email} e non può essere cambiato.", + "lock.page.isLocked": "la pagina viene attualmente modificata da {email} e non può essere cambiata.", + "lock.unlock": "Sblocca", + "lock.isUnlocked": "Un altro utente ha sovrascritto le tue modifiche non salvate. Puoi scaricarle per recuperarle e quindi incorporarle manualmente. ", + + "login": "Accedi", + "login.remember": "Resta collegato", + + "logout": "Esci", + + "menu": "Menu", + "meridiem": "AM/PM", + "mime": "MIME Type", + "minutes": "Minuti", + + "month": "Mese", + "months.april": "Aprile", + "months.august": "Agosto", + "months.december": "Dicembre", + "months.february": "Febbraio", + "months.january": "Gennaio", + "months.july": "Luglio", + "months.june": "Giugno", + "months.march": "Marzo", + "months.may": "Maggio", + "months.november": "Novembre", + "months.october": "Ottobre", + "months.september": "Settembre", + + "more": "Di più", + "name": "Nome", + "next": "Prossimo", + "off": "off", + "on": "on", + "open": "Apri", + "options": "Opzioni", + + "orientation": "Orientamento", + "orientation.landscape": "Panorama", + "orientation.portrait": "Ritratto", + "orientation.square": "Quadrato", + + "page.changeSlug": "Modifica URL", + "page.changeSlug.fromTitle": "Crea in base al titolo", + "page.changeStatus": "Cambia stato", + "page.changeStatus.position": "Scegli una posizione", + "page.changeStatus.select": "Seleziona un nuovo stato", + "page.changeTemplate": "Cambia template", + "page.delete.confirm": + "Sei sicuro di voler eliminare questa pagina?", + "page.delete.confirm.subpages": + "Questa pagina ha sottopagine.panel.install
옵션을 설정하세요.",
+ "installation.issues.accounts":
+ "폴더(/site/accounts
)에 쓰기 권한이 없습니다.",
+ "installation.issues.content":
+ "폴더(/content
)에 쓰기 권한이 없습니다.",
+ "installation.issues.curl": "cURL
확장 기능이 필요합니다.",
+ "installation.issues.headline": "패널을 설치할 수 없습니다.",
+ "installation.issues.mbstring":
+ "MB String
확장 기능이 필요합니다.",
+ "installation.issues.media":
+ "폴더(/media
)에 쓰기 권한이 없습니다.",
+ "installation.issues.php": "PHP
버전이 7 이상인지 확인하세요.",
+ "installation.issues.server":
+ "Apache
, Nginx
, 또는 Caddy
가 필요합니다.",
+ "installation.issues.sessions": "폴더(/site/sessions
)에 쓰기 권한이 없습니다.",
+
+ "language": "\uc5b8\uc5b4",
+ "language.code": "언어 코드",
+ "language.convert": "기본 언어로 설정",
+ "language.convert.confirm":
+ "이 언어({name})를 기본 언어로 설정할까요? 설정한 뒤에는 복원할 수 없으며, 번역되지 않은 항목은 올바르게 표시되지 않을 수 있습니다.",
+ "language.create": "새 언어 추가",
+ "language.delete.confirm":
+ "언어({name})를 삭제할까요? 삭제한 뒤에는 복원할 수 없습니다.",
+ "language.deleted": "언어를 삭제했습니다.",
+ "language.direction": "읽기 방향",
+ "language.direction.ltr": "왼쪽에서 오른쪽",
+ "language.direction.rtl": "오른쪽에서 왼쪽",
+ "language.locale": "PHP 로캘 문자열",
+ "language.locale.warning": "커스텀 언어 설정를 사용 중입니다. /site/languages 폴더의 언어 파일을 수정하세요.",
+ "language.name": "이름",
+ "language.updated": "언어를 변경했습니다.",
+
+ "languages": "언어",
+ "languages.default": "기본 언어",
+ "languages.empty": "언어가 없습니다.",
+ "languages.secondary": "보조 언어",
+ "languages.secondary.empty": "보조 언어가 없습니다.",
+
+ "license": "라이선스",
+ "license.buy": "라이선스 구매",
+ "license.register": "등록",
+ "license.register.help":
+ "이메일 주소로 라이선스 코드를 전송했습니다. Kirby를 등록하려면 라이선스 코드와 이메일 주소를 입력하세요.",
+ "license.register.label": "라이선스 코드를 입력하세요.",
+ "license.register.success": "Kirby를 구입해주셔서 감사합니다.",
+ "license.unregistered": "Kirby가 등록되지 않았습니다.",
+
+ "link": "\uc77c\ubc18 \ub9c1\ud06c",
+ "link.text": "\ubb38\uc790",
+
+ "loading": "로딩 중",
+
+ "lock.unsaved": "수정 사항이 저장되지 않았습니다.",
+ "lock.unsaved.empty": "저장되지 않은 페이지가 없습니다.",
+ "lock.isLocked": "다른 사용자({email})가 수정한 사항이 저장되지 않았습니다.",
+ "lock.file.isLocked": "다른 사용자({email})가 수정 중인 파일입니다.",
+ "lock.page.isLocked": "다른 사용자({email}가 수정 중인 페이지입니다.",
+ "lock.unlock": "잠금",
+ "lock.isUnlocked": "다른 사용자가 이미 내용을 수정했으므로 현재 내용이 올바르게 저장되지 않았습니다. 저장되지 않은 내용을 내려받아 수동으로 대치할 수 있습니다.",
+
+ "login": "\ub85c\uadf8\uc778",
+ "login.remember": "로그인 유지",
+
+ "logout": "\ub85c\uadf8\uc544\uc6c3",
+
+ "menu": "메뉴",
+ "meridiem": "오전/오후",
+ "mime": "형식",
+ "minutes": "분",
+
+ "month": "월",
+ "months.april": "4\uc6d4",
+ "months.august": "8\uc6d4",
+ "months.december": "12\uc6d4",
+ "months.february": "2\uc6d4",
+ "months.january": "1\uc6d4",
+ "months.july": "7\uc6d4",
+ "months.june": "6\uc6d4",
+ "months.march": "3\uc6d4",
+ "months.may": "5\uc6d4",
+ "months.november": "11\uc6d4",
+ "months.october": "10\uc6d4",
+ "months.september": "9\uc6d4",
+
+ "more": "더 보기",
+ "name": "이름",
+ "next": "다음",
+ "off": "끔",
+ "on": "켬",
+ "open": "열기",
+ "options": "옵션",
+
+ "orientation": "비율",
+ "orientation.landscape": "가로로 긴 사각형",
+ "orientation.portrait": "세로로 긴 사각형",
+ "orientation.square": "정사각형",
+
+ "page.changeSlug": "고유 주소 변경",
+ "page.changeSlug.fromTitle": "\uc81c\ubaa9(\uc601\ubb38)\uc5d0\uc11c \uac00\uc838\uc624\uae30",
+ "page.changeStatus": "상태 변경",
+ "page.changeStatus.position": "위치를 선택하세요.",
+ "page.changeStatus.select": "새 상태 선택",
+ "page.changeTemplate": "템플릿 변경",
+ "page.delete.confirm":
+ "페이지({title})를 삭제할까요?",
+ "page.delete.confirm.subpages":
+ "페이지에 하위 페이지가 있습니다. 모든 하위 페이지가 삭제됩니다.",
+ "page.delete.confirm.title": "페이지 제목을 입력하세요.",
+ "page.draft.create": "초안 작성",
+ "page.duplicate.appendix": "복사",
+ "page.duplicate.files": "파일 복사",
+ "page.duplicate.pages": "페이지 복사",
+ "page.status": "상태",
+ "page.status.draft": "초안",
+ "page.status.draft.description":
+ "로그인한 사용자만 읽을 수 있습니다.",
+ "page.status.listed": "공개",
+ "page.status.listed.description": "누구나 읽을 수 있습니다.",
+ "page.status.unlisted": "비공개",
+ "page.status.unlisted.description": "URL을 통해서만 접근할 수 있습니다.",
+
+ "pages": "하위 페이지",
+ "pages.empty": "페이지가 없습니다.",
+ "pages.status.draft": "초안",
+ "pages.status.listed": "발행",
+ "pages.status.unlisted": "비공개",
+
+ "pagination.page": "페이지",
+
+ "password": "\uc554\ud638",
+ "pixel": "픽셀",
+ "prev": "이전",
+ "remove": "삭제",
+ "rename": "제목 변경",
+ "replace": "\uad50\uccb4",
+ "retry": "\ub2e4\uc2dc \uc2dc\ub3c4",
+ "revert": "복원",
+
+ "role": "역할",
+ "role.admin.description": "관리자는 모든 권한이 있습니다.",
+ "role.admin.title": "관리자",
+ "role.all": "전체 보기",
+ "role.empty": "이 역할에 해당하는 사용자가 없습니다.",
+ "role.description.placeholder": "설명이 없습니다.",
+ "role.nobody.description": "대체 사용자는 아무 권한이 없습니다.",
+ "role.nobody.title": "사용자가 없습니다.",
+
+ "save": "\uc800\uc7a5",
+ "search": "검색",
+
+ "section.required": "섹션이 필요합니다.",
+
+ "select": "선택",
+ "settings": "설정",
+ "size": "크기",
+ "slug": "고유 주소",
+ "sort": "정렬",
+ "title": "제목",
+ "template": "\ud15c\ud50c\ub9bf",
+ "today": "오늘",
+
+ "toolbar.button.code": "코드",
+ "toolbar.button.bold": "강조 1",
+ "toolbar.button.email": "이메일 주소",
+ "toolbar.button.headings": "제목",
+ "toolbar.button.heading.1": "제목 1",
+ "toolbar.button.heading.2": "제목 2",
+ "toolbar.button.heading.3": "제목 3",
+ "toolbar.button.italic": "강조 2",
+ "toolbar.button.file": "파일",
+ "toolbar.button.file.select": "파일 선택",
+ "toolbar.button.file.upload": "파일 업로드",
+ "toolbar.button.link": "링크",
+ "toolbar.button.ol": "숫자 목록",
+ "toolbar.button.ul": "기호 목록",
+
+ "translation.author": "Kirby 팀",
+ "translation.direction": "LTR",
+ "translation.name": "한국어",
+ "translation.locale": "ko_KR",
+
+ "upload": "업로드",
+ "upload.error.cantMove": "업로드한 파일을 이동할 수 없습니다.",
+ "upload.error.cantWrite": "디스크를 읽을 수 없습니다.",
+ "upload.error.default": "파일을 업로드할 수 없습니다.",
+ "upload.error.extension": "파일 확장자를 다시 한번 확인하세요.",
+ "upload.error.formSize": "허용된 크기를 초과해 파일을 업로드할 수 없습니다.",
+ "upload.error.iniPostSize": "허용된 크기를 초과해 파일을 업로드할 수 없습니다.",
+ "upload.error.iniSize": "허용된 크기를 초과해 파일을 업로드할 수 없습니다.",
+ "upload.error.noFile": "업로드한 파일이 없습니다.",
+ "upload.error.noFiles": "업로드한 파일이 없습니다.",
+ "upload.error.partial": "일부 파일만 업로드했습니다.",
+ "upload.error.tmpDir": "임시 폴더가 없습니다.",
+ "upload.errors": "오류",
+ "upload.progress": "업로드 중…",
+
+ "url": "URL",
+ "url.placeholder": "https://example.com",
+
+ "user": "사용자",
+ "user.blueprint":
+ "/site/blueprints/users/{role}.yml 파일에 섹션 및 폼 필드를 추가할 수 있습니다.",
+ "user.changeEmail": "이메일 주소 변경",
+ "user.changeLanguage": "언어 변경",
+ "user.changeName": "사용자명 변경",
+ "user.changePassword": "암호 변경",
+ "user.changePassword.new": "새 암호",
+ "user.changePassword.new.confirm": "새 암호 확인",
+ "user.changeRole": "역할 변경",
+ "user.changeRole.select": "새 역할 선택",
+ "user.create": "사용자 추가",
+ "user.delete": "사용자 삭제",
+ "user.delete.confirm":
+ "사용자({email})를 삭제할까요?",
+
+ "users": "사용자",
+
+ "version": "버전",
+
+ "view.account": "계정",
+ "view.installation": "\uc124\uce58",
+ "view.settings": "설정",
+ "view.site": "사이트",
+ "view.users": "\uc0ac\uc6a9\uc790",
+
+ "welcome": "환영합니다.",
+ "year": "년"
+}
diff --git a/kirby/i18n/translations/lt.json b/kirby/i18n/translations/lt.json
new file mode 100755
index 0000000..bf14bed
--- /dev/null
+++ b/kirby/i18n/translations/lt.json
@@ -0,0 +1,481 @@
+{
+ "add": "Pridėti",
+ "avatar": "Profilio nuotrauka",
+ "back": "Atgal",
+ "cancel": "Atšaukti",
+ "change": "Keisti",
+ "close": "Uždaryti",
+ "confirm": "Ok",
+ "copy": "Kopijuoti",
+ "create": "Sukurti",
+
+ "date": "Data",
+ "date.select": "Pasirinkite datą",
+
+ "day": "Diena",
+ "days.fri": "Pen",
+ "days.mon": "Pir",
+ "days.sat": "Šeš",
+ "days.sun": "Sek",
+ "days.thu": "Ket",
+ "days.tue": "Ant",
+ "days.wed": "Tre",
+
+ "delete": "Pašalinti",
+ "dimensions": "Išmatavimai",
+ "disabled": "Išjungta",
+ "discard": "Atšaukti",
+ "download": "Parsisiųsti",
+ "duplicate": "Kopijuoti",
+ "edit": "Redaguoti",
+
+ "dialog.files.empty": "Nėra failų pasirinkimui",
+ "dialog.pages.empty": "Nėra puslapių pasirinkimui",
+ "dialog.users.empty": "Nėra vartotojų pasirinkimui",
+
+ "email": "El. paštas",
+ "email.placeholder": "mail@example.com",
+
+ "error.access.login": "Neteisingas prisijungimo vardas",
+ "error.access.panel": "Neturite teisės prisijungti prie valdymo pulto",
+ "error.access.view": "Neturite teisės peržiūrėti šios valdymo pulto dalies",
+
+ "error.avatar.create.fail": "Nepavyko įkelti profilio nuotraukos",
+ "error.avatar.delete.fail": "Nepavyko pašalinti profilio nuotraukos",
+ "error.avatar.dimensions.invalid":
+ "Profilio nuotraukos plotis ar aukštis turėtų būti iki 3000 pikselių",
+ "error.avatar.mime.forbidden":
+ "Profilio nuotrauka turi būti JPEG arba PNG",
+
+ "error.blueprint.notFound": "Blueprint \"{name}\" negali būti užkrautas",
+
+ "error.email.preset.notFound": "El. pašto paruoštukas \"{name}\" nerastas",
+
+ "error.field.converter.invalid": "Neteisingas konverteris \"{converter}\"",
+
+ "error.file.changeName.empty": "Pavadinimas negali būti tuščias",
+ "error.file.changeName.permission":
+ "Neturite teisės pakeisti failo pavadinimo \"{filename}\"",
+ "error.file.duplicate": "Failas su pavadinimu \"{filename}\" jau yra",
+ "error.file.extension.forbidden":
+ "Failo tipas (plėtinys) \"{extension}\" neleidžiamas",
+ "error.file.extension.missing":
+ "Failui \"{filename}\" trūksta tipo (plėtinio)",
+ "error.file.maxheight": "Failo aukštis neturi viršyti {height} px",
+ "error.file.maxsize": "Failas per didelis",
+ "error.file.maxwidth": "Failo plotis neturi viršyti {width} px",
+ "error.file.mime.differs":
+ "Įkėliamas failas turi būti tokio pat mime tipo \"{mime}\"",
+ "error.file.mime.forbidden": "Media tipas \"{mime}\" neleidžiamas",
+ "error.file.mime.invalid": "Neteisingas mime tipas: {mime}",
+ "error.file.mime.missing":
+ "Failui \"{filename}\" nepavyko atpažinti media (mime) tipo",
+ "error.file.minheight": "Failo aukštis turi būti bent {height} px",
+ "error.file.minsize": "Failas per mažas",
+ "error.file.minwidth": "Failo plotis turi būti bent {width} px",
+ "error.file.name.missing": "Failo pavadinimas negali būti tuščias",
+ "error.file.notFound": "Failas \"{filename}\" nerastas",
+ "error.file.orientation": "Failo orientacija turi būti \"{orientation}\"",
+ "error.file.type.forbidden": "Jūs neturite teisės įkelti {type} tipo failų",
+ "error.file.undefined": "Failas nerastas",
+
+ "error.form.incomplete": "🙏 Prašome ištaisyti visas formos klaidas…",
+ "error.form.notSaved": "Formos nepavyko išsaugoti",
+
+ "error.language.code": "Prašome įrašyti teisingą kalbos kodą",
+ "error.language.duplicate": "Tokia kalba jau yra",
+ "error.language.name": "Prašome įrašyti teisingą kalbos pavadinimą",
+
+ "error.license.format": "Prašome įrašyti teisingą licenzijos kodą",
+ "error.license.email": "Prašome įrašyti teisingą el. pašto adresą",
+ "error.license.verification": "Nepavyko patikrinti licenzijos",
+
+ "error.page.changeSlug.permission":
+ "Neturite teisės pakeisti \"{slug}\" URL",
+ "error.page.changeStatus.incomplete":
+ "Puslapis turi klaidų ir negali būti paskelbtas",
+ "error.page.changeStatus.permission":
+ "Šiam puslapiui negalima pakeisti statuso",
+ "error.page.changeStatus.toDraft.invalid":
+ "Puslapio \"{slug}\" negalima paversti juodraščiu",
+ "error.page.changeTemplate.invalid":
+ "Šablono puslapiui \"{slug}\" negalima keisti",
+ "error.page.changeTemplate.permission":
+ "Neturite leidimo keisti šabloną puslapiui \"{slug}\"",
+ "error.page.changeTitle.empty": "Pavadinimas negali būti tuščias",
+ "error.page.changeTitle.permission":
+ "Neturite leidimo keisti pavadinimo puslapiui \"{slug}\"",
+ "error.page.create.permission": "Neturite leidimo sukurti \"{slug}\"",
+ "error.page.delete": "Puslapio \"{slug}\" negalima pašalinti",
+ "error.page.delete.confirm": "Įrašykite puslapio pavadinimą, tam kad patvirtintumėte",
+ "error.page.delete.hasChildren":
+ "Puslapis turi vidinių puslapių, dėl to negalima jo pašalinti",
+ "error.page.delete.permission": "Neturite leidimo šalinti \"{slug}\"",
+ "error.page.draft.duplicate":
+ "Puslapio juodraštis su URL pabaiga \"{slug}\" jau yra",
+ "error.page.duplicate":
+ "Puslapis su URL pabaiga \"{slug}\" jau yra",
+ "error.page.duplicate.permission": "Neturite leidimo dubliuoti \"{slug}\"",
+ "error.page.notFound": "Puslapis \"{slug}\" nerastas",
+ "error.page.num.invalid":
+ "Įrašykite teisingą eiliškumo numerį. Numeris negali būti neigiamas.",
+ "error.page.slug.invalid": "Įrašykite teisingą URL prefiksą",
+ "error.page.sort.permission": "Puslapiui \"{slug}\" negalima pakeisti eiliškumo",
+ "error.page.status.invalid": "Nustatykite teisingą puslapio statusą",
+ "error.page.undefined": "Puslapis nerastas",
+ "error.page.update.permission": "Neturite leidimo atnaujinti \"{slug}\"",
+
+ "error.section.files.max.plural":
+ "Į sekciją \"{section}\" negalima pridėti daugiau nei {max} failų",
+ "error.section.files.max.singular":
+ "Į sekciją \"{section}\" negalima pridėti daugiau nei vieną failą",
+ "error.section.files.min.plural":
+ "Sekcija \"{section}\" reikalauja bent {min} failų",
+ "error.section.files.min.singular":
+ "Sekcija \"{section}\" reikalauja bent vieno failo",
+
+ "error.section.pages.max.plural":
+ "Į sekciją \"{section}\" negalima pridėti daugiau nei {max} puslapių",
+ "error.section.pages.max.singular":
+ "Į sekciją \"{section}\" negalima pridėti daugiau nei vieną puslapį",
+ "error.section.pages.min.plural":
+ "Sekcija \"{section}\" reikalauja bent {min} puslapių",
+ "error.section.pages.min.singular":
+ "Sekcija \"{section}\" reikalauja bent vieno puslapio",
+
+ "error.section.notLoaded": "Sekcija \"{name}\" negali būti užkrauta",
+ "error.section.type.invalid": "Sekcijos tipas \"{type}\" yra neteisingas",
+
+ "error.site.changeTitle.empty": "Pavadinimas negali būti tuščias",
+ "error.site.changeTitle.permission":
+ "Neturite leidimo keisti svetainės pavadinimo",
+ "error.site.update.permission": "Neturite leidimo atnaujinti svetainės",
+
+ "error.template.default.notFound": "Nėra šablono pagal nutylėjimą",
+
+ "error.user.changeEmail.permission":
+ "Neturite leidimo keisti vartotojo \"{name}\" el. paštą",
+ "error.user.changeLanguage.permission":
+ "Neturite leidimo keisti vartotojo \"{name}\" kalbą",
+ "error.user.changeName.permission":
+ "Neturite leidimo keisti vartotojo \"{name}\" vardą",
+ "error.user.changePassword.permission":
+ "Neturite leidimo keisti vartotojo \"{name}\" slaptažodį",
+ "error.user.changeRole.lastAdmin":
+ "Vienintelio administratoriaus rolės negalima pakeisti",
+ "error.user.changeRole.permission":
+ "Neturite leidimo pakeisti vartotojo \"{name}\" rolės",
+ "error.user.changeRole.toAdmin":
+ "Jūs neturite teisių suteikti administratoriaus rolę",
+ "error.user.create.permission": "Neturite leidimo sukurti šį vartotoją",
+ "error.user.delete": "Vartotojo \"{name}\" negalima pašalinti",
+ "error.user.delete.lastAdmin": "Vienintelio administratoriaus negalima pašalinti",
+ "error.user.delete.lastUser": "Vienintelio vartotojo negalima pašalinti",
+ "error.user.delete.permission":
+ "Neturite leidimo pašalinti vartotoją \"{name}\"",
+ "error.user.duplicate":
+ "Vartotojas su el. paštu \"{email}\" jau yra",
+ "error.user.email.invalid": "Įrašykite teisingą el. pašto adresą",
+ "error.user.language.invalid": "Įrašykite teisingą kalbą",
+ "error.user.notFound": "Vartotojas \"{name}\" nerastas",
+ "error.user.password.invalid":
+ "Prašome įrašyti galiojantį slaptažodį. Slaptažodį turi sudaryti bent 8 simboliai.",
+ "error.user.password.notSame": "Slaptažodžiai nesutampa",
+ "error.user.password.undefined": "Vartotojas neturi slaptažodžio",
+ "error.user.role.invalid": "Įrašykite teisingą rolę",
+ "error.user.update.permission":
+ "Neturite teisės keisti vartotojo \"{name}\"",
+
+ "error.validation.accepted": "Prašome patvirtinti",
+ "error.validation.alpha": "Prašome įrašyti tik raides a-z",
+ "error.validation.alphanum":
+ "Prašome įrašyti tik raides a-z arba skaičius 0-9",
+ "error.validation.between":
+ "Prašome įrašyti reikšmę tarp \"{min}\" ir \"{max}\"",
+ "error.validation.boolean": "Patvirtinkite arba atšaukite",
+ "error.validation.contains":
+ "Prašome įrašyti reikšmę, kuri turėtų \"{needle}\"",
+ "error.validation.date": "Prašome įrašyti korektišką datą",
+ "error.validation.date.after": "Įrašykite datą nuo {date}",
+ "error.validation.date.before": "Įrašykite datą iki {date}",
+ "error.validation.date.between": "Įrašykite datą tarp {min} ir {max}",
+ "error.validation.denied": "Prašome neleisti",
+ "error.validation.different": "Reikšmė neturi būti \"{other}\"",
+ "error.validation.email": "Prašome įrašyti korektišką el. paštą",
+ "error.validation.endswith": "Reikšmė turi baigtis su \"{end}\"",
+ "error.validation.filename": "Prašome įrašyti teisingą failo pavadinimą",
+ "error.validation.in": "Prašome įrašyti vieną iš šių: ({in})",
+ "error.validation.integer": "Prašome įrašyti teisingą sveiką skaičių",
+ "error.validation.ip": "Prašome įrašyti teisingą IP adresą",
+ "error.validation.less": "Prašome įrašyti mažiau nei {max}",
+ "error.validation.match": "Reikšmė nesutampa su laukiamu šablonu",
+ "error.validation.max": "Prašome įrašyti reikšmę lygią arba didesnę, nei {max}",
+ "error.validation.maxlength":
+ "Prašome įrašyti trumpesnę reikšmę. (max. {max} characters)",
+ "error.validation.maxwords": "Please enter no more than {max} word(s)",
+ "error.validation.min": "Please enter a value equal to or greater than {min}",
+ "error.validation.minlength":
+ "Prašome įrašyti ilgesnę reikšmę. (min. {min} characters)",
+ "error.validation.minwords": "Prašome įrašyti bent {min} žodžius",
+ "error.validation.more": "Prašome įrašyti daugiau nei {min}",
+ "error.validation.notcontains":
+ "Prašome įrašyti reikšmę, kuri neturi \"{needle}\"",
+ "error.validation.notin":
+ "Prašome neįrašyti vieną iš šių: ({notIn})",
+ "error.validation.option": "Prašome pasirinkti korektišką opciją",
+ "error.validation.num": "Prašome įrašyti teisingą numerį",
+ "error.validation.required": "Prašome įrašyti ką nors",
+ "error.validation.same": "Prašome įrašyti \"{other}\"",
+ "error.validation.size": "Reikšmės dydis turi būti \"{size}\"",
+ "error.validation.startswith": "Reikšmė turi prasidėti su \"{start}\"",
+ "error.validation.time": "Prašome įrašyti korektišką laiką",
+ "error.validation.url": "Prašome įrašyti teisingą URL",
+
+ "field.required": "Laukas privalomas",
+ "field.files.empty": "Pasirinkti",
+ "field.pages.empty": "Dar nėra puslapių",
+ "field.structure.delete.confirm": "Ar tikrai norite pašalinti šią eilutę?",
+ "field.structure.empty": "Dar nėra įrašų",
+ "field.users.empty": "Dar nėra vartotojų",
+
+ "file.delete.confirm":
+ "Ar tikrai norite pašalinti panel.install
opcija.",
+ "installation.issues.accounts":
+ "Katalogas /site/accounts
neegzistuoja arba neturi įrašymo teisių",
+ "installation.issues.content":
+ "Katalogas /content
neegzistuoja arba neturi įrašymo teisių",
+ "installation.issues.curl": "Plėtinys CURL
yra privalomas",
+ "installation.issues.headline": "Nepavyko įdiegti valdymo pulto",
+ "installation.issues.mbstring":
+ "Plėtinys MB String
yra privalomas",
+ "installation.issues.media":
+ "Katalogas /media
neegzistuoja arba neturi įrašymo teisių",
+ "installation.issues.php": "Įsitikinkite, kad naudojama PHP 7+
",
+ "installation.issues.server":
+ "Kirby reikalauja Apache
, Nginx
arba Caddy
",
+ "installation.issues.sessions": "Katalogas /site/sessions
neegzistuoja arba neturi įrašymo teisių",
+
+ "language": "Kalba",
+ "language.code": "Kodas",
+ "language.convert": "Padaryti pagrindinį",
+ "language.convert.confirm":
+ "Do you really want to convert {name} to the default language? This cannot be undone.
If {name} has untranslated content, there will no longer be a valid fallback and parts of your site might be empty.
", + "language.create": "Pridėti naują kalbą", + "language.delete.confirm": + "Ar tikrai norite pašalinti {name} kalbą, kartu su visais vertimais? Grąžinti nebus įmanoma! 🙀", + "language.deleted": "Kalba pašalinta", + "language.direction": "Skaitymo kryptis", + "language.direction.ltr": "Iš kairės į dešinę", + "language.direction.rtl": "Iš dešinės į kairę", + "language.locale": "PHP locale string", + "language.locale.warning": "Jūs naudojate pasirinktinį lokalės nustatymą. Prašome pakeisti jį faile /site/languages", + "language.name": "Pavadinimas", + "language.updated": "Kalba atnaujinta", + + "languages": "Kalbos", + "languages.default": "Pagrindinė kalba", + "languages.empty": "Dar nėra kalbų", + "languages.secondary": "Papildomos kalbos", + "languages.secondary.empty": "Dar nėra papildomų kalbų", + + "license": "Licenzija", + "license.buy": "Pirkti licenziją", + "license.register": "Registruoti", + "license.register.help": + "Licenzijos kodą gavote el. paštu po apmokėjimo. Prašome įterpti čia, kad sistema būtų užregistruota.", + "license.register.label": "Prašome įrašyti jūsų licenzijos kodą", + "license.register.success": "Ačiū, kad palaikote Kirby", + "license.unregistered": "Tai neregistruota Kirby demo versija", + + "link": "Nuoroda", + "link.text": "Nuorodos tekstas", + + "loading": "Kraunasi", + + "lock.unsaved": "Neišsaugoti pakeitimai", + "lock.unsaved.empty": "Nebeliko neišsaugotų pakeitimų", + "lock.isLocked": "Vartotojo {email} neišsaugoti pakeitimai", + "lock.file.isLocked": "Šį failą dabar redaguoja kitas vartotojas {email}, tad jo negalima pekeisti.", + "lock.page.isLocked": "Šį puslapį dabar redaguoja kitas vartotojas {email}, tad jo negalima pekeisti.", + "lock.unlock": "Atrakinti", + "lock.isUnlocked": "Jūsų neišsaugoti pakeitimai buvo perrašyti kito vartotojo. Galite parsisiųsti savo pakeitimus ir įkelti juos rankiniu būdu.", + + "login": "Prisijungti", + "login.remember": "Likti prisijungus", + + "logout": "Atsijungti", + + "menu": "Meniu", + "meridiem": "AM/PM", + "mime": "Media Tipas", + "minutes": "Minutės", + + "month": "Mėnuo", + "months.april": "Balandis", + "months.august": "August", + "months.december": "Gruodis", + "months.february": "Vasaris", + "months.january": "Sausis", + "months.july": "Liepa", + "months.june": "Birželis", + "months.march": "Kovas", + "months.may": "Gegužė", + "months.november": "Lapkritis", + "months.october": "Spalis", + "months.september": "Rugsėjis", + + "more": "Daugiau", + "name": "Pavadinimas", + "next": "Toliau", + "off": "off", + "on": "on", + "open": "Atidaryti", + "options": "Pasirinkimai", + + "orientation": "Orientacija", + "orientation.landscape": "Horizontali", + "orientation.portrait": "Portretas", + "orientation.square": "Kvadratas", + + "page.changeSlug": "Pakeisti URL", + "page.changeSlug.fromTitle": "Sukurti URL pagal pavadinimą", + "page.changeStatus": "Pakeisti statusą", + "page.changeStatus.position": "Pasirinkite poziciją", + "page.changeStatus.select": "Pasirinkite statusą", + "page.changeTemplate": "Pakeisti šabloną", + "page.delete.confirm": + "🙀 Ar tikrai norite pašalinti puslapį {title}?", + "page.delete.confirm.subpages": + "Šis puslapis turi sub-puslapių.panel.install
innstillingen.",
+ "installation.issues.accounts":
+ "\/site\/accounts er ikke skrivbar",
+ "installation.issues.content":
+ "Mappen content og alt av innhold m\u00e5 v\u00e6re skrivbar.",
+ "installation.issues.curl": "Utvidelsen CURL
er nødvendig",
+ "installation.issues.headline": "Panelet kan ikke installeres",
+ "installation.issues.mbstring":
+ "Utvidelsen MB String
er nødvendig",
+ "installation.issues.media":
+ "Mappen /media
eksisterer ikke eller er ikke skrivbar",
+ "installation.issues.php": "Pass på at du bruker PHP 7+
",
+ "installation.issues.server":
+ "Kirby krever Apache
, Nginx
eller Caddy
",
+ "installation.issues.sessions": "Mappen /site/sessions
eksisterer ikke eller er ikke skrivbar",
+
+ "language": "Spr\u00e5k",
+ "language.code": "Kode",
+ "language.convert": "Gjør til standard",
+ "language.convert.confirm":
+ "Vil du virkelig konvertere {name} til standardspråk? Dette kan ikke angres.
Dersom {name} har innhold som ikke er oversatt, vil nettstedet mangle innhold å falle tilbake på. Dette kan resultere i at deler av nettstedet fremstår som tomt.
", + "language.create": "Legg til språk", + "language.delete.confirm": + "Vil du virkelig slette språket {name} inkludert alle oversettelser? Dette kan ikke angres!", + "language.deleted": "Språket har blitt slettet", + "language.direction": "Leseretning", + "language.direction.ltr": "Venstre til høyre", + "language.direction.rtl": "Høyre til venstre", + "language.locale": "PHP locale streng", + "language.locale.warning": "You are using a custom locale set up. Please modify it in the language file in /site/languages", + "language.name": "Navn", + "language.updated": "Språk har blitt oppdatert", + + "languages": "Språk", + "languages.default": "Standardspråk", + "languages.empty": "Det er ingen språk ennå", + "languages.secondary": "Sekundære språk", + "languages.secondary.empty": "Det er ingen andre språk ennå", + + "license": "Kirby lisens", + "license.buy": "Kjøp lisens", + "license.register": "Registrer", + "license.register.help": + "Du skal ha mottatt din lisenskode for kjøpet via e-post. Vennligst kopier og lim inn denne for å registrere deg.", + "license.register.label": "Vennligst skriv inn din lisenskode", + "license.register.success": "Takk for at du støtter Kirby", + "license.unregistered": "Dette er en uregistrert demo av Kirby", + + "link": "Adresse", + "link.text": "Koblingstekst", + + "loading": "Laster inn", + + "lock.unsaved": "Unsaved changes", + "lock.unsaved.empty": "There are no more unsaved changes", + "lock.isLocked": "Unsaved changes by {email}", + "lock.file.isLocked": "The file is currently being edited by {email} and cannot be changed.", + "lock.page.isLocked": "The page is currently being edited by {email} and cannot be changed.", + "lock.unlock": "Unlock", + "lock.isUnlocked": "Your unsaved changes have been overwritten by another user. You can download your changes to merge them manually.", + + "login": "Logg Inn", + "login.remember": "Hold meg innlogget", + + "logout": "Logg ut", + + "menu": "Meny", + "meridiem": "AM/PM", + "mime": "Mediatype", + "minutes": "Minutter", + + "month": "Måned", + "months.april": "April", + "months.august": "August", + "months.december": "Desember", + "months.february": "Februar", + "months.january": "Januar", + "months.july": "July", + "months.june": "Juni", + "months.march": "Mars", + "months.may": "Mai", + "months.november": "November", + "months.october": "Oktober", + "months.september": "September", + + "more": "Mer", + "name": "Navn", + "next": "Neste", + "off": "off", + "on": "on", + "open": "Åpne", + "options": "Alternativer", + + "orientation": "Orientering", + "orientation.landscape": "Landskap", + "orientation.portrait": "Portrett", + "orientation.square": "Kvadrat", + + "page.changeSlug": "Endre URL", + "page.changeSlug.fromTitle": "Opprett fra tittel", + "page.changeStatus": "Endre status", + "page.changeStatus.position": "Vennligst velg en posisjon", + "page.changeStatus.select": "Velg ny status", + "page.changeTemplate": "Endre mal", + "page.delete.confirm": + "Vil du virkelig slette denne siden?", + "page.delete.confirm.subpages": + "Denne siden har undersider.panel.install
optie.",
+ "installation.issues.accounts":
+ "De map /site/accounts
heeft geen schrijfrechten",
+ "installation.issues.content":
+ "De map /content
bestaat niet of heeft geen schrijfrechten",
+ "installation.issues.curl": "De CURL
-extensie is vereist",
+ "installation.issues.headline": "Het Panel kan niet worden geïnstalleerd",
+ "installation.issues.mbstring":
+ "De MB String
extensie is verplicht",
+ "installation.issues.media":
+ "De map /media
bestaat niet of heeft geen schrijfrechten",
+ "installation.issues.php": "Gebruik PHP7+
",
+ "installation.issues.server":
+ "Kirby vereist Apache
, Nginx
of Caddy
",
+ "installation.issues.sessions": "De map /site/sessions
bestaat niet of heeft geen schrijfrechten",
+
+ "language": "Taal",
+ "language.code": "Code",
+ "language.convert": "Maak standaard",
+ "language.convert.confirm":
+ "Weet je zeker dat je {name}wilt aanpassen naar de standaard taal? Dit kan niet ongedaan worden gemaakt
Als {name} nog niet vertaalde content heeft, is er geen content meer om op terug te vallen en zouden delen van je site leeg kunnen zijn.
", + "language.create": "Nieuwe taal toevoegen", + "language.delete.confirm": + "Weet je zeker dat je de taal {name} inclusief alle vertalingen wilt verwijderen? Je kunt dit niet ongedaan maken!", + "language.deleted": "De taal is verwijderd", + "language.direction": "Leesrichting", + "language.direction.ltr": "Links naar rechts", + "language.direction.rtl": "Rechts naar links", + "language.locale": "PHP-locale regel", + "language.locale.warning": "You are using a custom locale set up. Please modify it in the language file in /site/languages", + "language.name": "Naam", + "language.updated": "De taal is geüpdatet", + + "languages": "Talen", + "languages.default": "Standaard taal", + "languages.empty": "Er zijn nog geen talen", + "languages.secondary": "Andere talen", + "languages.secondary.empty": "Er zijn nog geen andere talen beschikbaar", + + "license": "Licentie", + "license.buy": "Koop een licentie", + "license.register": "Registreren", + "license.register.help": + "Je hebt de licentie via e-mail gekregen nadat je de aankoop hebt gedaan. Kopieer en plak de licentie om te registreren. ", + "license.register.label": "Vul je licentie in", + "license.register.success": "Bedankt dat je Kirby ondersteunt", + "license.unregistered": "Dit is een niet geregistreerde demo van Kirby", + + "link": "Link", + "link.text": "Linktekst", + + "loading": "Laden", + + "lock.unsaved": "Niet opgeslagen wijzigingen", + "lock.unsaved.empty": "Er zijn geen niet opgeslagen wijzigingen meer", + "lock.isLocked": "Niet opgeslagen wijzigingen door {email}", + "lock.file.isLocked": "Dit bestand wordt momenteel bewerkt door {email} en kan niet worden gewijzigd.", + "lock.page.isLocked": "Deze pagina wordt momenteel bewerkt door {email} en kan niet worden gewijzigd.", + "lock.unlock": "Ontgrendelen", + "lock.isUnlocked": "Je niet opgeslagen wijzigingen zijn overschreven door een andere gebruiker. Je kunt je wijzigingen downloaden om ze handmatig samen te voegen.", + + "login": "Inloggen", + "login.remember": "Houd mij ingelogd", + + "logout": "Uitloggen", + + "menu": "Menu", + "meridiem": "AM/PM", + "mime": "Mime-type", + "minutes": "Minuten", + + "month": "Maand", + "months.april": "april", + "months.august": "augustus", + "months.december": "december", + "months.february": "februari", + "months.january": "januari", + "months.july": "juli", + "months.june": "juni", + "months.march": "maart", + "months.may": "mei", + "months.november": "november", + "months.october": "oktober", + "months.september": "september", + + "more": "Meer", + "name": "Naam", + "next": "Volgende", + "off": "uit", + "on": "aan", + "open": "Open", + "options": "Opties", + + "orientation": "Oriëntatie", + "orientation.landscape": "Liggend", + "orientation.portrait": "Staand", + "orientation.square": "Vierkant", + + "page.changeSlug": "Verander URL", + "page.changeSlug.fromTitle": "Aanmaken op basis van titel", + "page.changeStatus": "Wijzig status", + "page.changeStatus.position": "Selecteer een positie", + "page.changeStatus.select": "Selecteer een nieuwe status", + "page.changeTemplate": "Verander template", + "page.delete.confirm": + "Weet je zeker dat je pagina {title} wilt verwijderen?", + "page.delete.confirm.subpages": + "Deze pagina heeft subpagina's.panel.install
.",
+ "installation.issues.accounts":
+ "Folder /site/accounts
nie istnieje lub nie ma uprawnień do zapisu",
+ "installation.issues.content":
+ "Folder /content
nie istnieje lub nie ma uprawnień do zapisu",
+ "installation.issues.curl": "Wymagane jest rozszerzenie CURL
",
+ "installation.issues.headline": "Nie można zainstalować panelu",
+ "installation.issues.mbstring":
+ "Wymagane jest rozszerzenie MB String
",
+ "installation.issues.media":
+ "Folder /media
nie istnieje lub nie ma uprawnień do zapisu",
+ "installation.issues.php": "Upewnij się, że używasz PHP 7+
",
+ "installation.issues.server":
+ "Kirby wymaga Apache
, Nginx
lub Caddy
",
+ "installation.issues.sessions": "Folder /site/sessions
nie istnieje lub nie ma uprawnień do zapisu",
+
+ "language": "J\u0119zyk",
+ "language.code": "Kod",
+ "language.convert": "Ustaw jako domyślny",
+ "language.convert.confirm":
+ "Czy na pewno chcesz zmienić domyślny język na {name}? Nie można tego cofnąć.
Jeżeli brakuje tłumaczenia jakichś treści na {name}, nie będzie ich czym zastąpić i części witryny mogą być puste.
", + "language.create": "Dodaj nowy język", + "language.delete.confirm": + "Czy na pewno chcesz usunąć język {name} i wszystkie tłumaczenia? Tego nie da się cofnąć!", + "language.deleted": "Język został usunięty", + "language.direction": "Kierunek czytania", + "language.direction.ltr": "Od lewej do prawej", + "language.direction.rtl": "Od prawej do lewej", + "language.locale": "PHP locale string", + "language.locale.warning": "You are using a custom locale set up. Please modify it in the language file in /site/languages", + "language.name": "Nazwa", + "language.updated": "Język został zaktualizowany", + + "languages": "Języki", + "languages.default": "Domyślny język", + "languages.empty": "Nie ma jeszcze żadnych języków", + "languages.secondary": "Dodatkowe języki", + "languages.secondary.empty": "Nie ma jeszcze dodatkowych języków", + + "license": "Licencja", + "license.buy": "Kup licencję", + "license.register": "Zarejestruj", + "license.register.help": + "Po zakupieniu licencji otrzymałaś/-eś mailem klucz. Skopiuj go i wklej tutaj, aby dokonać rejestracji.", + "license.register.label": "Wprowadź swój kod licencji", + "license.register.success": "Dziękujemy za wspieranie Kirby", + "license.unregistered": "To jest niezarejestrowana wersja demonstracyjna Kirby", + + "link": "Link", + "link.text": "Tekst linku", + + "loading": "Ładuję", + + "lock.unsaved": "Niezapisane zmiany", + "lock.unsaved.empty": "There are no more unsaved changes", + "lock.isLocked": "Niezapisane zmiany autorstwa {email}", + "lock.file.isLocked": "The file is currently being edited by {email} and cannot be changed.", + "lock.page.isLocked": "The page is currently being edited by {email} and cannot be changed.", + "lock.unlock": "Unlock", + "lock.isUnlocked": "Your unsaved changes have been overwritten by another user. You can download your changes to merge them manually.", + + "login": "Zaloguj", + "login.remember": "Nie wylogowuj mnie", + + "logout": "Wyloguj", + + "menu": "Menu", + "meridiem": "AM/PM", + "mime": "Typ multimediów", + "minutes": "Minuty", + + "month": "Miesiąc", + "months.april": "Kwiecie\u0144", + "months.august": "Sierpie\u0144", + "months.december": "Grudzie\u0144", + "months.february": "Luty", + "months.january": "Stycze\u0144", + "months.july": "Lipiec", + "months.june": "Czerwiec", + "months.march": "Marzec", + "months.may": "Maj", + "months.november": "Listopad", + "months.october": "Pa\u017adziernik", + "months.september": "Wrzesie\u0144", + + "more": "Więcej", + "name": "Nazwa", + "next": "Następne", + "off": "off", + "on": "on", + "open": "Otwórz", + "options": "Opcje", + + "orientation": "Orientacja", + "orientation.landscape": "Pozioma", + "orientation.portrait": "Pionowa", + "orientation.square": "Kwadrat", + + "page.changeSlug": "Zmie\u0144 URL", + "page.changeSlug.fromTitle": "Utw\u00f3rz na podstawie tytu\u0142u", + "page.changeStatus": "Zmień status", + "page.changeStatus.position": "Wybierz pozycję", + "page.changeStatus.select": "Wybierz nowy status", + "page.changeTemplate": "Zmień szablon", + "page.delete.confirm": + "Czy na pewno chcesz usunąć {title}?", + "page.delete.confirm.subpages": + "Ta strona zawiera podstrony.panel.install
.",
+ "installation.issues.accounts":
+ "A pasta /site/accounts
não existe ou não possui permissão de escrita",
+ "installation.issues.content":
+ "A pasta /content
não existe ou não possui permissão de escrita",
+ "installation.issues.curl": "A extensão CURL
é necessária",
+ "installation.issues.headline": "O painel não pôde ser instalado",
+ "installation.issues.mbstring":
+ "A extensão MB String
é necessária",
+ "installation.issues.media":
+ "A pasta /media
não existe ou não possui permissão de escrita",
+ "installation.issues.php": "Certifique-se que você está usando o PHP 7+
",
+ "installation.issues.server":
+ "Kirby necessita do Apache
, Nginx
ou Caddy
",
+ "installation.issues.sessions": "A pasta /site/sessions
não existe ou não possui permissão de escrita",
+
+ "language": "Idioma",
+ "language.code": "Código",
+ "language.convert": "Tornar padrão",
+ "language.convert.confirm":
+ "Deseja realmente converter {name} para o idioma padrão? Esta ação não poderá ser revertida.
Se {name} tiver conteúdo não traduzido, partes do seu site poderão ficar sem conteúdo.
", + "language.create": "Adicionar novo idioma", + "language.delete.confirm": + "Deseja realmente excluir o idioma {name} incluíndo todas as traduções. Esta ação não poderá ser revertida!", + "language.deleted": "Idioma excluído", + "language.direction": "Direção de leitura", + "language.direction.ltr": "Esquerda para direita", + "language.direction.rtl": "Direita para esquerda", + "language.locale": "String de localização do PHP", + "language.locale.warning": "You are using a custom locale set up. Please modify it in the language file in /site/languages", + "language.name": "Nome", + "language.updated": "Idioma atualizado", + + "languages": "Idiomas", + "languages.default": "Idioma padrão", + "languages.empty": "Nenhum idioma", + "languages.secondary": "Idiomas secundários", + "languages.secondary.empty": "Nenhum idioma secundário", + + "license": "Licen\u00e7a do Kirby ", + "license.buy": "Comprar licença", + "license.register": "Registrar", + "license.register.help": + "Você recebeu o código da sua licença por email após a compra. Por favor, copie e cole-a para completar o registro.", + "license.register.label": "Por favor, digite o código da sua licença", + "license.register.success": "Obrigado por apoiar o Kirby", + "license.unregistered": "Esta é uma demonstração não registrada do Kirby", + + "link": "Link", + "link.text": "Texto do link", + + "loading": "Carregando", + + "lock.unsaved": "Unsaved changes", + "lock.unsaved.empty": "There are no more unsaved changes", + "lock.isLocked": "Unsaved changes by {email}", + "lock.file.isLocked": "The file is currently being edited by {email} and cannot be changed.", + "lock.page.isLocked": "The page is currently being edited by {email} and cannot be changed.", + "lock.unlock": "Unlock", + "lock.isUnlocked": "Your unsaved changes have been overwritten by another user. You can download your changes to merge them manually.", + + "login": "Entrar", + "login.remember": "Manter-me conectado", + + "logout": "Sair", + + "menu": "Menu", + "meridiem": "AM/PM", + "mime": "Tipo de mídia", + "minutes": "Minutos", + + "month": "Mês", + "months.april": "Abril", + "months.august": "Agosto", + "months.december": "Dezembro", + "months.february": "Fevereiro", + "months.january": "Janeiro", + "months.july": "Julho", + "months.june": "Junho", + "months.march": "Mar\u00e7o", + "months.may": "Maio", + "months.november": "Novembro", + "months.october": "Outubro", + "months.september": "Setembro", + + "more": "Mais", + "name": "Nome", + "next": "Próximo", + "off": "off", + "on": "on", + "open": "Abrir", + "options": "Opções", + + "orientation": "Orientação", + "orientation.landscape": "Paisagem", + "orientation.portrait": "Retrato", + "orientation.square": "Quadrado", + + "page.changeSlug": "Alterar URL", + "page.changeSlug.fromTitle": "Criar a partir do t\u00edtulo", + "page.changeStatus": "Alterar estado", + "page.changeStatus.position": "Selecione uma posição", + "page.changeStatus.select": "Selecione um novo estado", + "page.changeTemplate": "Alterar tema", + "page.delete.confirm": + "Deseja realmente excluir {title}?", + "page.delete.confirm.subpages": + "Esta página possui subpáginas.panel.install
.",
+ "installation.issues.accounts":
+ "A pasta /site/accounts
não existe ou não possui permissão de escrita",
+ "installation.issues.content":
+ "A pasta /content
não existe ou não possui permissão de escrita",
+ "installation.issues.curl": "A extensão CURL
é necessária",
+ "installation.issues.headline": "O painel não pôde ser instalado",
+ "installation.issues.mbstring":
+ "A extensão MB String
é necessária",
+ "installation.issues.media":
+ "A pasta /media
não existe ou não possui permissão de escrita",
+ "installation.issues.php": "Certifique-se que está a usar o PHP 7+
",
+ "installation.issues.server":
+ "O Kirby necessita do Apache
, Nginx
ou Caddy
",
+ "installation.issues.sessions": "A pasta /site/sessions
não existe ou não possui permissão de escrita",
+
+ "language": "Idioma",
+ "language.code": "Código",
+ "language.convert": "Tornar padrão",
+ "language.convert.confirm":
+ "Deseja realmente converter {name} para o idioma padrão? Esta ação não poderá ser revertida.
Se {name} tiver conteúdo não traduzido, partes do seu site poderão ficar sem conteúdo.
", + "language.create": "Adicionar novo idioma", + "language.delete.confirm": + "Deseja realmente excluir o idioma {name} incluíndo todas as traduções? Esta ação não poderá ser revertida!", + "language.deleted": "Idioma excluído", + "language.direction": "Direção de leitura", + "language.direction.ltr": "Esquerda para direita", + "language.direction.rtl": "Direita para esquerda", + "language.locale": "String de localização do PHP", + "language.locale.warning": "You are using a custom locale set up. Please modify it in the language file in /site/languages", + "language.name": "Nome", + "language.updated": "Idioma atualizado", + + "languages": "Idiomas", + "languages.default": "Idioma padrão", + "languages.empty": "Nenhum idioma", + "languages.secondary": "Idiomas secundários", + "languages.secondary.empty": "Nenhum idioma secundário", + + "license": "Licen\u00e7a do Kirby ", + "license.buy": "Comprar uma licença", + "license.register": "Registrar", + "license.register.help": + "Recebeu o código da sua licença por email após a compra. Por favor, copie e cole-o para completar o registro.", + "license.register.label": "Por favor, digite o código da sua licença", + "license.register.success": "Obrigado por apoiar o Kirby", + "license.unregistered": "Esta é uma demonstração não registrada do Kirby", + + "link": "Link", + "link.text": "Texto do link", + + "loading": "A carregar", + + "lock.unsaved": "Unsaved changes", + "lock.unsaved.empty": "There are no more unsaved changes", + "lock.isLocked": "Unsaved changes by {email}", + "lock.file.isLocked": "The file is currently being edited by {email} and cannot be changed.", + "lock.page.isLocked": "The page is currently being edited by {email} and cannot be changed.", + "lock.unlock": "Unlock", + "lock.isUnlocked": "Your unsaved changes have been overwritten by another user. You can download your changes to merge them manually.", + + "login": "Entrar", + "login.remember": "Manter-me conectado", + + "logout": "Sair", + + "menu": "Menu", + "meridiem": "AM/PM", + "mime": "Tipo de mídia", + "minutes": "Minutos", + + "month": "Mês", + "months.april": "Abril", + "months.august": "Agosto", + "months.december": "Dezembro", + "months.february": "Fevereiro", + "months.january": "Janeiro", + "months.july": "Julho", + "months.june": "Junho", + "months.march": "Mar\u00e7o", + "months.may": "Maio", + "months.november": "Novembro", + "months.october": "Outubro", + "months.september": "Setembro", + + "more": "Mais", + "name": "Nome", + "next": "Próximo", + "off": "off", + "on": "on", + "open": "Abrir", + "options": "Opções", + + "orientation": "Orientação", + "orientation.landscape": "Paisagem", + "orientation.portrait": "Retrato", + "orientation.square": "Quadrado", + + "page.changeSlug": "Alterar URL", + "page.changeSlug.fromTitle": "Criar a partir do t\u00edtulo", + "page.changeStatus": "Alterar estado", + "page.changeStatus.position": "Selecione uma posição", + "page.changeStatus.select": "Selecione um novo estado", + "page.changeTemplate": "Alterar tema", + "page.delete.confirm": + "Deseja realmente excluir {title}?", + "page.delete.confirm.subpages": + "Esta página possui subpáginas.panel.install
",
+ "installation.issues.accounts":
+ "Каталог /site/accounts
не существует или не имеет прав записи",
+ "installation.issues.content":
+ "Каталог /content
не существует или не имеет прав записи",
+ "installation.issues.curl": "Расширение CURL
необходимо",
+ "installation.issues.headline": "Не удалось установить панель",
+ "installation.issues.mbstring":
+ "Расширение MB String
необходимо",
+ "installation.issues.media":
+ "Каталог /media
не существует или нет прав записи",
+ "installation.issues.php": "Убедитесь, что используется PHP 7+
",
+ "installation.issues.server":
+ "Kirby требует Apache
, Nginx
или Caddy
",
+ "installation.issues.sessions": "Каталог /site/sessions
не существует или нет прав записи",
+
+ "language": "\u042f\u0437\u044b\u043a",
+ "language.code": "Код",
+ "language.convert": "Установить по умолчанию",
+ "language.convert.confirm":
+ "Вы точно хотите конвертировать {name} в главный язык? Это нельзя будет отменить.
Если {name} имеет непереведенный контент, то больше не будет верного каскада и части вашего сайта могут быть пустыми.
", + "language.create": "Добавить новый язык", + "language.delete.confirm": + "Вы точно хотите удалить {name} язык, включая все переводы? Это нельзя будет вернуть.", + "language.deleted": "Язык удален", + "language.direction": "Направление чтения", + "language.direction.ltr": "Слева направо", + "language.direction.rtl": "Справа налево", + "language.locale": "PHP locale string", + "language.locale.warning": "Вы используете кастомную локаль. Пожалуйста измените ее в файле языка в /site/languages", + "language.name": "Название", + "language.updated": "Язык обновлен", + + "languages": "Языки", + "languages.default": "Главный язык", + "languages.empty": "Еще нет языков", + "languages.secondary": "Дополнительные языки", + "languages.secondary.empty": "Еще нет дополнительных языков", + + "license": "\u041b\u0438\u0446\u0435\u043d\u0437\u0438\u044f Kirby", + "license.buy": "Купить лицензию", + "license.register": "Зарегистрировать", + "license.register.help": + "После покупки вы получили по эл. почте код лицензии. Пожалуйста скопируйте и вставьте сюда чтобы зарегистрировать.", + "license.register.label": "Пожалуйста вставьте код лицензии", + "license.register.success": "Спасибо за поддержку Kirby", + "license.unregistered": "Это незарегистрированная версия Kirby", + + "link": "\u0421\u0441\u044b\u043b\u043a\u0430", + "link.text": "\u0422\u0435\u043a\u0441\u0442 \u0441\u0441\u044b\u043b\u043a\u0438", + + "loading": "Загрузка", + + "lock.unsaved": "Несохраненные изменения", + "lock.unsaved.empty": "Больше нет несохраненных изменений", + "lock.isLocked": "Несохраненные изменения пользователя {email}", + "lock.file.isLocked": "В данный момент этот файл редактирует {email}, поэтому его нельзя изменить.", + "lock.page.isLocked": "В данный момент эту страницу редактирует {email}, поэтому его нельзя изменить.", + "lock.unlock": "Разблокировать", + "lock.isUnlocked": "Ваши несохраненные изменения были перезаписаны другим пользователем. Вы можете загрузить ваши изменения и объединить их вручную.", + + "login": "Войти", + "login.remember": "Сохранять вход активным", + + "logout": "Выйти", + + "menu": "Меню", + "meridiem": "До полудня / После полудня", + "mime": "Тип медиа", + "minutes": "Минуты", + + "month": "Месяц", + "months.april": "\u0410\u043f\u0440\u0435\u043b\u044c", + "months.august": "\u0410\u0432\u0433\u0443\u0441\u0442", + "months.december": "\u0414\u0435\u043a\u0430\u0431\u0440\u044c", + "months.february": "\u0424\u0435\u0432\u0440\u0430\u043b\u044c", + "months.january": "\u042f\u043d\u0432\u0430\u0440\u044c", + "months.july": "\u0418\u044e\u043b\u044c", + "months.june": "\u0418\u044e\u043d\u044c", + "months.march": "\u041c\u0430\u0440\u0442", + "months.may": "\u041c\u0430\u0439", + "months.november": "\u041d\u043e\u044f\u0431\u0440\u044c", + "months.october": "\u041e\u043a\u0442\u044f\u0431\u0440\u044c", + "months.september": "\u0421\u0435\u043d\u0442\u044f\u0431\u0440\u044c", + + "more": "Подробнее", + "name": "Название", + "next": "Дальше", + "off": "выключено", + "on": "включено", + "open": "Открыть", + "options": "Опции", + + "orientation": "Ориентация", + "orientation.landscape": "Горизонтальная", + "orientation.portrait": "Портретная", + "orientation.square": "Квадрат", + + "page.changeSlug": "\u0418\u0437\u043c\u0435\u043d\u0438\u0442\u044c \u0441\u0441\u044b\u043b\u043a\u0443 (\u0427\u041f\u0423)", + "page.changeSlug.fromTitle": "Создать из названия", + "page.changeStatus": "Изменить статус", + "page.changeStatus.position": "Пожалуйста, выберите позицию", + "page.changeStatus.select": "Выбрать новый статус", + "page.changeTemplate": "Поменять шаблон", + "page.delete.confirm": + "Вы точно хотите удалить эту страницу?", + "page.delete.confirm.subpages": + "У этой страницы есть внутренние страницы.panel.install
.",
+ "installation.issues.accounts":
+ "Priečinok /site/accounts
neexistuje alebo nie je nastavený ako zapisovateľný",
+ "installation.issues.content":
+ "Priečinok /content
neexistuje alebo nie je nastavený ako zapisovateľný",
+ "installation.issues.curl": "CURL
rozšírenie je povinné",
+ "installation.issues.headline": "Panel nie je možné naištalovať",
+ "installation.issues.mbstring":
+ "MB String
rozšírenie je povinné",
+ "installation.issues.media":
+ "Priečinok /media
neexistuje alebo nie je nastavený ako zapisovateľný",
+ "installation.issues.php": "Uistite sa, že používate PHP 7+
",
+ "installation.issues.server":
+ "Kirby vyžaduje Apache
, Nginx
alebo Caddy
",
+ "installation.issues.sessions": "Priečinok /site/sessions
neexistuje alebo nie je nastavený ako zapisovateľný",
+
+ "language": "Jazyk",
+ "language.code": "Kód",
+ "language.convert": "Nastaviť ako predvolené",
+ "language.convert.confirm":
+ "Ste si istý, že chcete nastaviť {name} ako predvolený jazyk? Túto akciu nie je možné zvrátiť.
Ak {name} obsahuje nepreložený obsah, tak pre tento obsah nebude fungovať platné volanie a niektoré časti vašich stránok zostanú prázdne.
", + "language.create": "Pridať nový jazyk", + "language.delete.confirm": + "Ste si istý, že chcete zmazať jazyk {name} vrátane všetkých prekladov? Túto akciu nie je možné zvrátiť.", + "language.deleted": "Jazyk bol zmazaný", + "language.direction": "Smer čítania", + "language.direction.ltr": "Zľava doprava", + "language.direction.rtl": "Zprava doľava", + "language.locale": "PHP locale string", + "language.locale.warning": "You are using a custom locale set up. Please modify it in the language file in /site/languages", + "language.name": "Názov", + "language.updated": "Jazyk bol aktualizovaný", + + "languages": "Jazyky", + "languages.default": "Predvolený jazyk", + "languages.empty": "Zatiaľ žiadne jazyky", + "languages.secondary": "Sekundárne jazyky", + "languages.secondary.empty": "Zatiaľ žiadne sekundárne jazyky", + + "license": "Licencia", + "license.buy": "Zakúpiť licenciu", + "license.register": "Registrovať", + "license.register.help": + "Licenčný kód vám bol doručený e-mailom po úspešnom nákupe. Prosím, skopírujte a prilepte ho na uskutočnenie registrácie.", + "license.register.label": "Prosím, zadajte váš licenčný kód", + "license.register.success": "Ďakujeme za vašu podporu Kirby", + "license.unregistered": "Toto je neregistrované demo Kirby", + + "link": "Odkaz", + "link.text": "Text odkazu", + + "loading": "Načítavanie", + + "lock.unsaved": "Unsaved changes", + "lock.unsaved.empty": "There are no more unsaved changes", + "lock.isLocked": "Unsaved changes by {email}", + "lock.file.isLocked": "The file is currently being edited by {email} and cannot be changed.", + "lock.page.isLocked": "The page is currently being edited by {email} and cannot be changed.", + "lock.unlock": "Unlock", + "lock.isUnlocked": "Your unsaved changes have been overwritten by another user. You can download your changes to merge them manually.", + + "login": "Prihlásenie", + "login.remember": "Ponechať ma prihláseného", + + "logout": "Odhlásenie", + + "menu": "Menu", + "meridiem": "AM/PM", + "mime": "Typ média", + "minutes": "Minúty", + + "month": "Mesiac", + "months.april": "Apríl", + "months.august": "August", + "months.december": "December", + "months.february": "Február", + "months.january": "Január", + "months.july": "Júl", + "months.june": "Jún", + "months.march": "Marec", + "months.may": "Máj", + "months.november": "November", + "months.october": "Október", + "months.september": "September", + + "more": "Viac", + "name": "Meno", + "next": "Ďalej", + "off": "off", + "on": "on", + "open": "Otvoriť", + "options": "Nastavenia", + + "orientation": "Orientácia", + "orientation.landscape": "Širokouhlá", + "orientation.portrait": "Portrét", + "orientation.square": "Štvorec", + + "page.changeSlug": "Zmeniť URL", + "page.changeSlug.fromTitle": "Vytvoriť z titulku", + "page.changeStatus": "Zmeniť status", + "page.changeStatus.position": "Prosím, zmeňte pozíciu", + "page.changeStatus.select": "Zvoľte nový status", + "page.changeTemplate": "Zmeniť šablónu", + "page.delete.confirm": + "Ste si istý, že chcete zmazať {title}?", + "page.delete.confirm.subpages": + "Táto stránka obsahuje podstránky.panel.install
.",
+ "installation.issues.accounts":
+ "Mappen /site/accounts
finns inte eller är inte skrivbar",
+ "installation.issues.content":
+ "Mappen /content
finns inte eller är inte skrivbar",
+ "installation.issues.curl": "Tillägget CURL
krävs",
+ "installation.issues.headline": "Panelen kan inte installeras",
+ "installation.issues.mbstring":
+ "Tillägget MB String
krävs",
+ "installation.issues.media":
+ "Mappen /media
finns inte eller är inte skrivbar",
+ "installation.issues.php": "Se till att du använder PHP 7+
",
+ "installation.issues.server":
+ "Kirby kräver Apache
, Nginx
eller Caddy
",
+ "installation.issues.sessions": "Mappen /site/sessions
finns inte eller är inte skrivbar",
+
+ "language": "Spr\u00e5k",
+ "language.code": "Kod",
+ "language.convert": "Ange som standard",
+ "language.convert.confirm":
+ "Vill du verkligen göra {name} till standardspråket? Detta kan inte ångras.
Om {name} har oöversatt innehåll, kommer det inte längre finnas en alternativ översättning och delar av sajten kommer kanske att vara tom.
", + "language.create": "Lägg till ett nytt språk", + "language.delete.confirm": + "Vill du verkligen radera språket {name} inklusive alla översättningar? Detta kan inte ångras!", + "language.deleted": "Språket har raderats", + "language.direction": "Läsriktning", + "language.direction.ltr": "Vänster till höger", + "language.direction.rtl": "Höger till vänster", + "language.locale": "PHP locale string", + "language.locale.warning": "Du använder en anpassad språkinställning. Ändra den i språkfilen i mappen /site/languages", + "language.name": "Namn", + "language.updated": "Språket har uppdaterats", + + "languages": "Språk", + "languages.default": "Standardspråk", + "languages.empty": "Det finns inga språk ännu", + "languages.secondary": "Sekundära språk", + "languages.secondary.empty": "Det finns inga sekundära språk ännu", + + "license": "Licens", + "license.buy": "Köp en licens", + "license.register": "Registrera", + "license.register.help": + "Du fick din licenskod via e-post efter inköpet. Kopiera och klistra in den för att registrera licensen.", + "license.register.label": "Ange din licenskod", + "license.register.success": "Tack för att du stödjer Kirby", + "license.unregistered": "Detta är en oregistrerad demo av Kirby", + + "link": "L\u00e4nk", + "link.text": "L\u00e4nktext", + + "loading": "Laddar", + + "lock.unsaved": "Osparade ändringar", + "lock.unsaved.empty": "Det finns inga fler osparade ändringar", + "lock.isLocked": "Osparade ändringar av {email}", + "lock.file.isLocked": "Filen redigeras just nu av {email} och kan inte redigeras.", + "lock.page.isLocked": "Sidan redigeras just nu av {email} och kan inte redigeras.", + "lock.unlock": "Lås upp", + "lock.isUnlocked": "Dina osparade ändringar har skrivits över av en annan användare. Du kan ladda ner dina ändringar för att slå ihop dem manuellt.", + + "login": "Logga in", + "login.remember": "Håll mig inloggad", + + "logout": "Logga ut", + + "menu": "Meny", + "meridiem": "a.m./p.m.", + "mime": "Mediatyp", + "minutes": "Minuter", + + "month": "Månad", + "months.april": "April", + "months.august": "Augusti", + "months.december": "December", + "months.february": "Februari", + "months.january": "Januari", + "months.july": "Juli", + "months.june": "Juni", + "months.march": "Mars", + "months.may": "Maj", + "months.november": "November", + "months.october": "Oktober", + "months.september": "September", + + "more": "Mer", + "name": "Namn", + "next": "Nästa", + "off": "av", + "on": "på", + "open": "Öppna", + "options": "Alternativ", + + "orientation": "Orientering", + "orientation.landscape": "Liggande", + "orientation.portrait": "Stående", + "orientation.square": "Kvadrat", + + "page.changeSlug": "Ändra URL", + "page.changeSlug.fromTitle": "Skapa utifr\u00e5n titel", + "page.changeStatus": "Ändra status", + "page.changeStatus.position": "Välj en ny position", + "page.changeStatus.select": "Välj en ny status", + "page.changeTemplate": "Ändra mall", + "page.delete.confirm": + "Vill du verkligen radera {title}?", + "page.delete.confirm.subpages": + "Denna sida har undersidor.panel.install
seçeneğiyle etkinleştirin.",
+ "installation.issues.accounts":
+ "/site/accounts
klasörü yok yada yazılabilir değil",
+ "installation.issues.content":
+ "/content
klasörü yok yada yazılabilir değil",
+ "installation.issues.curl": "CURL
eklentisi gerekli",
+ "installation.issues.headline": "Panel kurulamadı",
+ "installation.issues.mbstring":
+ "MB String
eklentisi gerekli",
+ "installation.issues.media":
+ "/media
klasörü yok yada yazılamaz",
+ "installation.issues.php": "PHP 7+
kullandığınızdan emin olun. ",
+ "installation.issues.server":
+ "Kirby Apache
, Nginx
or Caddy
gerektirir",
+ "installation.issues.sessions": "/site/sessions
klasörü mevcut değil veya yazılabilir değil",
+
+ "language": "Dil",
+ "language.code": "Kod",
+ "language.convert": "Varsayılan yap",
+ "language.convert.confirm":
+ "{name}'i varsayılan dile dönüştürmek istiyor musunuz? Bu geri alınamaz.
{name} çevrilmemiş içeriğe sahipse, artık geçerli bir geri dönüş olmaz ve sitenizin bazı bölümleri boş olabilir.
", + "language.create": "Yeni bir dil ekle", + "language.delete.confirm": + "Tüm çevirileri içeren {name} dilini gerçekten silmek istiyor musunuz? Bu geri alınamaz!", + "language.deleted": "Dil silindi", + "language.direction": "Okuma yönü", + "language.direction.ltr": "Soldan sağa", + "language.direction.rtl": "Sağdan sola", + "language.locale": "PHP yerel dizesi", + "language.locale.warning": "Özel bir yerel ayar kullanıyorsunuz. Lütfen /site/languages konumundaki dil dosyasından değiştirin.", + "language.name": "İsim", + "language.updated": "Dil güncellendi", + + "languages": "Diller", + "languages.default": "Varsayılan dil", + "languages.empty": "Henüz hiç dil yok", + "languages.secondary": "İkincil diller", + "languages.secondary.empty": "Henüz ikincil bir dil yok", + + "license": "Lisans", + "license.buy": "Bir lisans satın al", + "license.register": "Kayıt Ol", + "license.register.help": + "Satın alma işleminden sonra e-posta yoluyla lisans kodunuzu aldınız. Lütfen kayıt olmak için kodu kopyalayıp yapıştırın.", + "license.register.label": "Lütfen lisans kodunu giriniz", + "license.register.success": "Kirby'yi desteklediğiniz için teşekkürler", + "license.unregistered": "Bu Kirby'nin kayıtsız bir demosu", + + "link": "Ba\u011flant\u0131", + "link.text": "Ba\u011flant\u0131 yaz\u0131s\u0131", + + "loading": "Yükleniyor", + + "lock.unsaved": "Kaydedilmemiş değişiklikler", + "lock.unsaved.empty": "Daha fazla kaydedilmemiş değişiklik yok", + "lock.isLocked": "{email} tarafından kaydedilmemiş değişiklikler", + "lock.file.isLocked": "Dosya şu anda {email} tarafından düzenlenmektedir ve değiştirilemez.", + "lock.page.isLocked": "Sayfa şu anda {email} tarafından düzenlenmektedir ve değiştirilemez.", + "lock.unlock": "Kilidi Aç", + "lock.isUnlocked": "Kaydedilmemiş değişikliklerin üzerine başka bir kullanıcı yazmış. Değişikliklerinizi el ile birleştirmek için değişikliklerinizi indirebilirsiniz.", + + "login": "Giri\u015f", + "login.remember": "Oturumumu açık tut", + + "logout": "Güvenli Çıkış", + + "menu": "Menü", + "meridiem": "AM/PM", + "mime": "Medya Türü", + "minutes": "Dakika", + + "month": "Ay", + "months.april": "Nisan", + "months.august": "A\u011fustos", + "months.december": "Aral\u0131k", + "months.february": "\u015eubat", + "months.january": "Ocak", + "months.july": "Temmuz", + "months.june": "Haziran", + "months.march": "Mart", + "months.may": "May\u0131s", + "months.november": "Kas\u0131m", + "months.october": "Ekim", + "months.september": "Eyl\u00fcl", + + "more": "Daha Fazla", + "name": "İsim", + "next": "Sonraki", + "off": "kapalı", + "on": "açık", + "open": "Önizleme", + "options": "Seçenekler", + + "orientation": "Oryantasyon", + "orientation.landscape": "Yatay", + "orientation.portrait": "Dikey", + "orientation.square": "Kare", + + "page.changeSlug": "Web Adresini Değiştir", + "page.changeSlug.fromTitle": "Ba\u015fl\u0131ktan olu\u015ftur", + "page.changeStatus": "Durumu değiştir", + "page.changeStatus.position": "Lütfen bir pozisyon seçin", + "page.changeStatus.select": "Yeni bir durum seçin", + "page.changeTemplate": "Şablonu değiştir", + "page.delete.confirm": + "{title} sayfasını silmek istediğinizden emin misiniz?", + "page.delete.confirm.subpages": + "Bu sayfada alt sayfalar var.
+ * // put an item in the cache for 15 minutes
+ * $cache->set('value', 'my value', 15);
+ *
+ *
+ * @param string $key
+ * @param mixed $value
+ * @param int $minutes
+ * @return bool
+ */
+ public function set(string $key, $value, int $minutes = 0): bool
+ {
+ return apcu_store($this->key($key), (new Value($value, $minutes))->toJson(), $this->expiration($minutes));
+ }
+}
diff --git a/kirby/src/Cache/Cache.php b/kirby/src/Cache/Cache.php
new file mode 100755
index 0000000..729d61b
--- /dev/null
+++ b/kirby/src/Cache/Cache.php
@@ -0,0 +1,242 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+abstract class Cache
+{
+ /**
+ * Stores all options for the driver
+ * @var array
+ */
+ protected $options = [];
+
+ /**
+ * Sets all parameters which are needed to connect to the cache storage
+ *
+ * @param array $options
+ */
+ public function __construct(array $options = [])
+ {
+ $this->options = $options;
+ }
+
+ /**
+ * Writes an item to the cache for a given number of minutes and
+ * returns whether the operation was successful;
+ * this needs to be defined by the driver
+ *
+ *
+ * // put an item in the cache for 15 minutes
+ * $cache->set('value', 'my value', 15);
+ *
+ *
+ * @param string $key
+ * @param mixed $value
+ * @param int $minutes
+ * @return bool
+ */
+ abstract public function set(string $key, $value, int $minutes = 0): bool;
+
+ /**
+ * Adds the prefix to the key if given
+ *
+ * @param string $key
+ * @return string
+ */
+ protected function key(string $key): string
+ {
+ if (empty($this->options['prefix']) === false) {
+ $key = $this->options['prefix'] . '/' . $key;
+ }
+
+ return $key;
+ }
+
+ /**
+ * Internal method to retrieve the raw cache value;
+ * needs to return a Value object or null if not found;
+ * this needs to be defined by the driver
+ *
+ * @param string $key
+ * @return \Kirby\Cache\Value|null
+ */
+ abstract public function retrieve(string $key);
+
+ /**
+ * Gets an item from the cache
+ *
+ *
+ * // get an item from the cache driver
+ * $value = $cache->get('value');
+ *
+ * // return a default value if the requested item isn't cached
+ * $value = $cache->get('value', 'default value');
+ *
+ *
+ * @param string $key
+ * @param mixed $default
+ * @return mixed
+ */
+ public function get(string $key, $default = null)
+ {
+ // get the Value
+ $value = $this->retrieve($key);
+
+ // check for a valid cache value
+ if (!is_a($value, 'Kirby\Cache\Value')) {
+ return $default;
+ }
+
+ // remove the item if it is expired
+ if ($value->expires() > 0 && time() >= $value->expires()) {
+ $this->remove($key);
+ return $default;
+ }
+
+ // return the pure value
+ return $value->value();
+ }
+
+ /**
+ * Calculates the expiration timestamp
+ *
+ * @param int $minutes
+ * @return int
+ */
+ protected function expiration(int $minutes = 0): int
+ {
+ // 0 = keep forever
+ if ($minutes === 0) {
+ return 0;
+ }
+
+ // calculate the time
+ return time() + ($minutes * 60);
+ }
+
+ /**
+ * Checks when an item in the cache expires;
+ * returns the expiry timestamp on success, null if the
+ * item never expires and false if the item does not exist
+ *
+ * @param string $key
+ * @return int|null|false
+ */
+ public function expires(string $key)
+ {
+ // get the Value object
+ $value = $this->retrieve($key);
+
+ // check for a valid Value object
+ if (!is_a($value, 'Kirby\Cache\Value')) {
+ return false;
+ }
+
+ // return the expires timestamp
+ return $value->expires();
+ }
+
+ /**
+ * Checks if an item in the cache is expired
+ *
+ * @param string $key
+ * @return bool
+ */
+ public function expired(string $key): bool
+ {
+ $expires = $this->expires($key);
+
+ if ($expires === null) {
+ return false;
+ } elseif (!is_int($expires)) {
+ return true;
+ } else {
+ return time() >= $expires;
+ }
+ }
+
+ /**
+ * Checks when the cache has been created;
+ * returns the creation timestamp on success
+ * and false if the item does not exist
+ *
+ * @param string $key
+ * @return int|false
+ */
+ public function created(string $key)
+ {
+ // get the Value object
+ $value = $this->retrieve($key);
+
+ // check for a valid Value object
+ if (!is_a($value, 'Kirby\Cache\Value')) {
+ return false;
+ }
+
+ // return the expires timestamp
+ return $value->created();
+ }
+
+ /**
+ * Alternate version for Cache::created($key)
+ *
+ * @param string $key
+ * @return int|false
+ */
+ public function modified(string $key)
+ {
+ return static::created($key);
+ }
+
+ /**
+ * Determines if an item exists in the cache
+ *
+ * @param string $key
+ * @return bool
+ */
+ public function exists(string $key): bool
+ {
+ return $this->expired($key) === false;
+ }
+
+ /**
+ * Removes an item from the cache and returns
+ * whether the operation was successful;
+ * this needs to be defined by the driver
+ *
+ * @param string $key
+ * @return bool
+ */
+ abstract public function remove(string $key): bool;
+
+ /**
+ * Flushes the entire cache and returns
+ * whether the operation was successful;
+ * this needs to be defined by the driver
+ *
+ * @return bool
+ */
+ abstract public function flush(): bool;
+
+ /**
+ * Returns all passed cache options
+ *
+ * @return array
+ */
+ public function options(): array
+ {
+ return $this->options;
+ }
+}
diff --git a/kirby/src/Cache/FileCache.php b/kirby/src/Cache/FileCache.php
new file mode 100755
index 0000000..1e50ff4
--- /dev/null
+++ b/kirby/src/Cache/FileCache.php
@@ -0,0 +1,155 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class FileCache extends Cache
+{
+ /**
+ * Full root including prefix
+ * @var string
+ */
+ protected $root;
+
+ /**
+ * Sets all parameters which are needed for the file cache
+ *
+ * @param array $options 'root' (required)
+ * 'prefix' (default: none)
+ * 'extension' (file extension for cache files, default: none)
+ */
+ public function __construct(array $options)
+ {
+ $defaults = [
+ 'root' => null,
+ 'prefix' => null,
+ 'extension' => null
+ ];
+
+ parent::__construct(array_merge($defaults, $options));
+
+ // build the full root including prefix
+ $this->root = $this->options['root'];
+ if (empty($this->options['prefix']) === false) {
+ $this->root .= '/' . $this->options['prefix'];
+ }
+
+ // try to create the directory
+ Dir::make($this->root, true);
+ }
+
+ /**
+ * Returns the full path to a file for a given key
+ *
+ * @param string $key
+ * @return string
+ */
+ protected function file(string $key): string
+ {
+ $file = $this->root . '/' . $key;
+
+ if (isset($this->options['extension'])) {
+ return $file . '.' . $this->options['extension'];
+ } else {
+ return $file;
+ }
+ }
+
+ /**
+ * Writes an item to the cache for a given number of minutes and
+ * returns whether the operation was successful
+ *
+ *
+ * // put an item in the cache for 15 minutes
+ * $cache->set('value', 'my value', 15);
+ *
+ *
+ * @param string $key
+ * @param mixed $value
+ * @param int $minutes
+ * @return bool
+ */
+ public function set(string $key, $value, int $minutes = 0): bool
+ {
+ $file = $this->file($key);
+
+ return F::write($file, (new Value($value, $minutes))->toJson());
+ }
+
+ /**
+ * Internal method to retrieve the raw cache value;
+ * needs to return a Value object or null if not found
+ *
+ * @param string $key
+ * @return \Kirby\Cache\Value|null
+ */
+ public function retrieve(string $key)
+ {
+ $file = $this->file($key);
+
+ return Value::fromJson(F::read($file));
+ }
+
+ /**
+ * Checks when the cache has been created;
+ * returns the creation timestamp on success
+ * and false if the item does not exist
+ *
+ * @param string $key
+ * @return mixed
+ */
+ public function created(string $key)
+ {
+ // use the modification timestamp
+ // as indicator when the cache has been created/overwritten
+ clearstatcache();
+
+ // get the file for this cache key
+ $file = $this->file($key);
+ return file_exists($file) ? filemtime($this->file($key)) : false;
+ }
+
+ /**
+ * Removes an item from the cache and returns
+ * whether the operation was successful
+ *
+ * @param string $key
+ * @return bool
+ */
+ public function remove(string $key): bool
+ {
+ $file = $this->file($key);
+
+ if (is_file($file) === true) {
+ return F::remove($file);
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Flushes the entire cache and returns
+ * whether the operation was successful
+ *
+ * @return bool
+ */
+ public function flush(): bool
+ {
+ if (Dir::remove($this->root) === true && Dir::make($this->root) === true) {
+ return true;
+ }
+
+ return false; // @codeCoverageIgnore
+ }
+}
diff --git a/kirby/src/Cache/MemCached.php b/kirby/src/Cache/MemCached.php
new file mode 100755
index 0000000..82cff09
--- /dev/null
+++ b/kirby/src/Cache/MemCached.php
@@ -0,0 +1,97 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class MemCached extends Cache
+{
+ /**
+ * store for the memache connection
+ * @var Memcached
+ */
+ protected $connection;
+
+ /**
+ * Sets all parameters which are needed to connect to Memcached
+ *
+ * @param array $options 'host' (default: localhost)
+ * 'port' (default: 11211)
+ * 'prefix' (default: null)
+ */
+ public function __construct(array $options = [])
+ {
+ $defaults = [
+ 'host' => 'localhost',
+ 'port' => 11211,
+ 'prefix' => null,
+ ];
+
+ parent::__construct(array_merge($defaults, $options));
+
+ $this->connection = new \Memcached();
+ $this->connection->addServer($this->options['host'], $this->options['port']);
+ }
+
+ /**
+ * Writes an item to the cache for a given number of minutes and
+ * returns whether the operation was successful
+ *
+ *
+ * // put an item in the cache for 15 minutes
+ * $cache->set('value', 'my value', 15);
+ *
+ *
+ * @param string $key
+ * @param mixed $value
+ * @param int $minutes
+ * @return bool
+ */
+ public function set(string $key, $value, int $minutes = 0): bool
+ {
+ return $this->connection->set($this->key($key), (new Value($value, $minutes))->toJson(), $this->expiration($minutes));
+ }
+
+ /**
+ * Internal method to retrieve the raw cache value;
+ * needs to return a Value object or null if not found
+ *
+ * @param string $key
+ * @return \Kirby\Cache\Value|null
+ */
+ public function retrieve(string $key)
+ {
+ return Value::fromJson($this->connection->get($this->key($key)));
+ }
+
+ /**
+ * Removes an item from the cache and returns
+ * whether the operation was successful
+ *
+ * @param string $key
+ * @return bool
+ */
+ public function remove(string $key): bool
+ {
+ return $this->connection->delete($this->key($key));
+ }
+
+ /**
+ * Flushes the entire cache and returns
+ * whether the operation was successful;
+ * WARNING: Memcached only supports flushing the whole cache at once!
+ *
+ * @return bool
+ */
+ public function flush(): bool
+ {
+ return $this->connection->flush();
+ }
+}
diff --git a/kirby/src/Cache/MemoryCache.php b/kirby/src/Cache/MemoryCache.php
new file mode 100755
index 0000000..7f2d098
--- /dev/null
+++ b/kirby/src/Cache/MemoryCache.php
@@ -0,0 +1,82 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class MemoryCache extends Cache
+{
+ /**
+ * Cache data
+ * @var array
+ */
+ protected $store = [];
+
+ /**
+ * Writes an item to the cache for a given number of minutes and
+ * returns whether the operation was successful
+ *
+ *
+ * // put an item in the cache for 15 minutes
+ * $cache->set('value', 'my value', 15);
+ *
+ *
+ * @param string $key
+ * @param mixed $value
+ * @param int $minutes
+ * @return bool
+ */
+ public function set(string $key, $value, int $minutes = 0): bool
+ {
+ $this->store[$key] = new Value($value, $minutes);
+ return true;
+ }
+
+ /**
+ * Internal method to retrieve the raw cache value;
+ * needs to return a Value object or null if not found
+ *
+ * @param string $key
+ * @return \Kirby\Cache\Value|null
+ */
+ public function retrieve(string $key)
+ {
+ return $this->store[$key] ?? null;
+ }
+
+ /**
+ * Removes an item from the cache and returns
+ * whether the operation was successful
+ *
+ * @param string $key
+ * @return bool
+ */
+ public function remove(string $key): bool
+ {
+ if (isset($this->store[$key])) {
+ unset($this->store[$key]);
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Flushes the entire cache and returns
+ * whether the operation was successful
+ *
+ * @return bool
+ */
+ public function flush(): bool
+ {
+ $this->store = [];
+ return true;
+ }
+}
diff --git a/kirby/src/Cache/NullCache.php b/kirby/src/Cache/NullCache.php
new file mode 100755
index 0000000..a33fc9c
--- /dev/null
+++ b/kirby/src/Cache/NullCache.php
@@ -0,0 +1,69 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class NullCache extends Cache
+{
+ /**
+ * Writes an item to the cache for a given number of minutes and
+ * returns whether the operation was successful
+ *
+ *
+ * // put an item in the cache for 15 minutes
+ * $cache->set('value', 'my value', 15);
+ *
+ *
+ * @param string $key
+ * @param mixed $value
+ * @param int $minutes
+ * @return bool
+ */
+ public function set(string $key, $value, int $minutes = 0): bool
+ {
+ return true;
+ }
+
+ /**
+ * Internal method to retrieve the raw cache value;
+ * needs to return a Value object or null if not found
+ *
+ * @param string $key
+ * @return \Kirby\Cache\Value|null
+ */
+ public function retrieve(string $key)
+ {
+ return null;
+ }
+
+ /**
+ * Removes an item from the cache and returns
+ * whether the operation was successful
+ *
+ * @param string $key
+ * @return bool
+ */
+ public function remove(string $key): bool
+ {
+ return true;
+ }
+
+ /**
+ * Flushes the entire cache and returns
+ * whether the operation was successful
+ *
+ * @return bool
+ */
+ public function flush(): bool
+ {
+ return true;
+ }
+}
diff --git a/kirby/src/Cache/Value.php b/kirby/src/Cache/Value.php
new file mode 100755
index 0000000..038965c
--- /dev/null
+++ b/kirby/src/Cache/Value.php
@@ -0,0 +1,144 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Value
+{
+ /**
+ * Cached value
+ * @var mixed
+ */
+ protected $value;
+
+ /**
+ * the number of minutes until the value expires
+ * @var int
+ */
+ protected $minutes;
+
+ /**
+ * Creation timestamp
+ * @var int
+ */
+ protected $created;
+
+ /**
+ * Constructor
+ *
+ * @param mixed $value
+ * @param int $minutes the number of minutes until the value expires
+ * @param int $created the unix timestamp when the value has been created
+ */
+ public function __construct($value, int $minutes = 0, int $created = null)
+ {
+ $this->value = $value;
+ $this->minutes = $minutes ?? 0;
+ $this->created = $created ?? time();
+ }
+
+ /**
+ * Returns the creation date as UNIX timestamp
+ *
+ * @return int
+ */
+ public function created(): int
+ {
+ return $this->created;
+ }
+
+ /**
+ * Returns the expiration date as UNIX timestamp or
+ * null if the value never expires
+ *
+ * @return int|null
+ */
+ public function expires(): ?int
+ {
+ // 0 = keep forever
+ if ($this->minutes === 0) {
+ return null;
+ }
+
+ return $this->created + ($this->minutes * 60);
+ }
+
+ /**
+ * Creates a value object from an array
+ *
+ * @param array $array
+ * @return self
+ */
+ public static function fromArray(array $array)
+ {
+ return new static($array['value'] ?? null, $array['minutes'] ?? 0, $array['created'] ?? null);
+ }
+
+ /**
+ * Creates a value object from a JSON string;
+ * returns null on error
+ *
+ * @param string $json
+ * @return self|null
+ */
+ public static function fromJson(string $json)
+ {
+ try {
+ $array = json_decode($json, true);
+
+ if (is_array($array)) {
+ return static::fromArray($array);
+ } else {
+ return null;
+ }
+ } catch (Throwable $e) {
+ return null;
+ }
+ }
+
+ /**
+ * Converts the object to a JSON string
+ *
+ * @return string
+ */
+ public function toJson(): string
+ {
+ return json_encode($this->toArray());
+ }
+
+ /**
+ * Converts the object to an array
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return [
+ 'created' => $this->created,
+ 'minutes' => $this->minutes,
+ 'value' => $this->value,
+ ];
+ }
+
+ /**
+ * Returns the pure value
+ *
+ * @return mixed
+ */
+ public function value()
+ {
+ return $this->value;
+ }
+}
diff --git a/kirby/src/Cms/Api.php b/kirby/src/Cms/Api.php
new file mode 100755
index 0000000..08c9a9e
--- /dev/null
+++ b/kirby/src/Cms/Api.php
@@ -0,0 +1,270 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://getkirby.com/license
+ */
+class Api extends BaseApi
+{
+ /**
+ * @var App
+ */
+ protected $kirby;
+
+ /**
+ * Execute an API call for the given path,
+ * request method and optional request data
+ *
+ * @param string $path
+ * @param string $method
+ * @param array $requestData
+ * @return mixed
+ */
+ public function call(string $path = null, string $method = 'GET', array $requestData = [])
+ {
+ $this->setRequestMethod($method);
+ $this->setRequestData($requestData);
+
+ $this->kirby->setCurrentLanguage($this->language());
+
+ if ($user = $this->kirby->user()) {
+ $this->kirby->setCurrentTranslation($user->language());
+ }
+
+ return parent::call($path, $method, $requestData);
+ }
+
+ /**
+ * @param mixed $model
+ * @param string $name
+ * @param string $path
+ * @return mixed
+ */
+ public function fieldApi($model, string $name, string $path = null)
+ {
+ $form = Form::for($model);
+ $fieldNames = Str::split($name, '+');
+ $index = 0;
+ $count = count($fieldNames);
+ $field = null;
+
+ foreach ($fieldNames as $fieldName) {
+ $index++;
+
+ if ($field = $form->fields()->get($fieldName)) {
+ if ($count !== $index) {
+ $form = $field->form();
+ }
+ } else {
+ throw new NotFoundException('The field "' . $fieldName . '" could not be found');
+ }
+ }
+
+ if ($field === null) {
+ throw new NotFoundException('The field "' . $fieldNames . '" could not be found');
+ }
+
+ $fieldApi = $this->clone([
+ 'routes' => $field->api(),
+ 'data' => array_merge($this->data(), ['field' => $field])
+ ]);
+
+ return $fieldApi->call($path, $this->requestMethod(), $this->requestData());
+ }
+
+ /**
+ * Returns the file object for the given
+ * parent path and filename
+ *
+ * @param string $path Path to file's parent model
+ * @param string $filename Filename
+ * @return \Kirby\Cms\File|null
+ */
+ public function file(string $path = null, string $filename)
+ {
+ $filename = urldecode($filename);
+
+ if ($file = $this->parent($path)->file($filename)) {
+ return $file;
+ }
+
+ throw new NotFoundException([
+ 'key' => 'file.notFound',
+ 'data' => [
+ 'filename' => $filename
+ ]
+ ]);
+ }
+
+ /**
+ * Returns the model's object for the given path
+ *
+ * @param string $path Path to parent model
+ * @return \Kirby\Cms\Model|null
+ */
+ public function parent(string $path)
+ {
+ $modelType = in_array($path, ['site', 'account']) ? $path : trim(dirname($path), '/');
+ $modelTypes = [
+ 'site' => 'site',
+ 'users' => 'user',
+ 'pages' => 'page',
+ 'account' => 'account'
+ ];
+ $modelName = $modelTypes[$modelType] ?? null;
+
+ if (Str::endsWith($modelType, '/files') === true) {
+ $modelName = 'file';
+ }
+
+ $kirby = $this->kirby();
+
+ switch ($modelName) {
+ case 'site':
+ $model = $kirby->site();
+ break;
+ case 'account':
+ $model = $kirby->user();
+ break;
+ case 'page':
+ $id = str_replace(['+', ' '], '/', basename($path));
+ $model = $kirby->page($id);
+ break;
+ case 'file':
+ $model = $this->file(...explode('/files/', $path));
+ break;
+ case 'user':
+ $model = $kirby->user(basename($path));
+ break;
+ default:
+ throw new InvalidArgumentException('Invalid file model type: ' . $modelType);
+ }
+
+ if ($model) {
+ return $model;
+ }
+
+ throw new NotFoundException([
+ 'key' => $modelName . '.undefined'
+ ]);
+ }
+
+ /**
+ * Returns the Kirby instance
+ *
+ * @return \Kirby\Cms\App
+ */
+ public function kirby()
+ {
+ return $this->kirby;
+ }
+
+ /**
+ * Returns the language request header
+ *
+ * @return string|null
+ */
+ public function language(): ?string
+ {
+ return get('language') ?? $this->requestHeaders('x-language');
+ }
+
+ /**
+ * Returns the page object for the given id
+ *
+ * @param string $id Page's id
+ * @return \Kirby\Cms\Page|null
+ */
+ public function page(string $id)
+ {
+ $id = str_replace('+', '/', $id);
+ $page = $this->kirby->page($id);
+
+ if ($page && $page->isReadable()) {
+ return $page;
+ }
+
+ throw new NotFoundException([
+ 'key' => 'page.notFound',
+ 'data' => [
+ 'slug' => $id
+ ]
+ ]);
+ }
+
+ public function session(array $options = [])
+ {
+ return $this->kirby->session(array_merge([
+ 'detect' => true
+ ], $options));
+ }
+
+ /**
+ * @param \Kirby\Cms\App $kirby
+ */
+ protected function setKirby(App $kirby)
+ {
+ $this->kirby = $kirby;
+ return $this;
+ }
+
+ /**
+ * Returns the site object
+ *
+ * @return \Kirby\Cms\Site
+ */
+ public function site()
+ {
+ return $this->kirby->site();
+ }
+
+ /**
+ * Returns the user object for the given id or
+ * returns the current authenticated user if no
+ * id is passed
+ *
+ * @param string $id User's id
+ * @return \Kirby\Cms\User|null
+ */
+ public function user(string $id = null)
+ {
+ // get the authenticated user
+ if ($id === null) {
+ return $this->kirby->auth()->user();
+ }
+
+ // get a specific user by id
+ if ($user = $this->kirby->users()->find($id)) {
+ return $user;
+ }
+
+ throw new NotFoundException([
+ 'key' => 'user.notFound',
+ 'data' => [
+ 'name' => $id
+ ]
+ ]);
+ }
+
+ /**
+ * Returns the users collection
+ *
+ * @return \Kirby\Cms\Users
+ */
+ public function users()
+ {
+ return $this->kirby->users();
+ }
+}
diff --git a/kirby/src/Cms/App.php b/kirby/src/Cms/App.php
new file mode 100755
index 0000000..2db02fc
--- /dev/null
+++ b/kirby/src/Cms/App.php
@@ -0,0 +1,1398 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://getkirby.com/license
+ */
+class App
+{
+ const CLASS_ALIAS = 'kirby';
+
+ use AppCaches;
+ use AppErrors;
+ use AppPlugins;
+ use AppTranslations;
+ use AppUsers;
+ use Properties;
+
+ protected static $instance;
+ protected static $root;
+ protected static $version;
+
+ public $data = [];
+
+ protected $api;
+ protected $collections;
+ protected $defaultLanguage;
+ protected $language;
+ protected $languages;
+ protected $locks;
+ protected $multilang;
+ protected $nonce;
+ protected $options;
+ protected $path;
+ protected $request;
+ protected $response;
+ protected $roles;
+ protected $roots;
+ protected $routes;
+ protected $router;
+ protected $server;
+ protected $sessionHandler;
+ protected $site;
+ protected $system;
+ protected $urls;
+ protected $user;
+ protected $users;
+ protected $visitor;
+
+ /**
+ * Creates a new App instance
+ *
+ * @param array $props
+ */
+ public function __construct(array $props = [])
+ {
+ // the kirby folder directory
+ static::$root = dirname(__DIR__, 2);
+
+ // register all roots to be able to load stuff afterwards
+ $this->bakeRoots($props['roots'] ?? []);
+
+ // stuff from config and additional options
+ $this->optionsFromConfig();
+ $this->optionsFromProps($props['options'] ?? []);
+
+ // set the path to make it available for the url bakery
+ $this->setPath($props['path'] ?? null);
+
+ // create all urls after the config, so possible
+ // options can be taken into account
+ $this->bakeUrls($props['urls'] ?? []);
+
+ // configurable properties
+ $this->setOptionalProperties($props, [
+ 'languages',
+ 'request',
+ 'roles',
+ 'site',
+ 'user',
+ 'users'
+ ]);
+
+ // set the singleton
+ Model::$kirby = static::$instance = $this;
+
+ // setup the I18n class with the translation loader
+ $this->i18n();
+
+ // load all extensions
+ $this->extensionsFromSystem();
+ $this->extensionsFromProps($props);
+ $this->extensionsFromPlugins();
+ $this->extensionsFromOptions();
+ $this->extensionsFromFolders();
+
+ // trigger hook for use in plugins
+ $this->trigger('system.loadPlugins:after');
+
+ // handle those damn errors
+ $this->handleErrors();
+
+ // execute a ready callback from the config
+ $this->optionsFromReadyCallback();
+
+ // bake config
+ Config::$data = $this->options;
+ }
+
+ /**
+ * Improved `var_dump` output
+ *
+ * @return array
+ */
+ public function __debugInfo(): array
+ {
+ return [
+ 'languages' => $this->languages(),
+ 'options' => $this->options(),
+ 'request' => $this->request(),
+ 'roots' => $this->roots(),
+ 'site' => $this->site(),
+ 'urls' => $this->urls(),
+ 'version' => $this->version(),
+ ];
+ }
+
+ /**
+ * Returns the Api instance
+ *
+ * @internal
+ * @return \Kirby\Cms\Api
+ */
+ public function api()
+ {
+ if ($this->api !== null) {
+ return $this->api;
+ }
+
+ $root = static::$root . '/config/api';
+ $extensions = $this->extensions['api'] ?? [];
+ $routes = (include $root . '/routes.php')($this);
+
+ $api = [
+ 'debug' => $this->option('debug', false),
+ 'authentication' => $extensions['authentication'] ?? include $root . '/authentication.php',
+ 'data' => $extensions['data'] ?? [],
+ 'collections' => array_merge($extensions['collections'] ?? [], include $root . '/collections.php'),
+ 'models' => array_merge($extensions['models'] ?? [], include $root . '/models.php'),
+ 'routes' => array_merge($routes, $extensions['routes'] ?? []),
+ 'kirby' => $this,
+ ];
+
+ return $this->api = new Api($api);
+ }
+
+ /**
+ * Applies a hook to the given value;
+ * the value that gets modified by the hooks
+ * is always the last argument
+ *
+ * @internal
+ * @param string $name Hook name
+ * @param mixed ...$args Arguments to pass to the hooks
+ * @return mixed Resulting value as modified by the hooks
+ */
+ public function apply(string $name, ...$args)
+ {
+ // split up args into "passive" args and the value
+ $value = array_pop($args);
+
+ if ($functions = $this->extension('hooks', $name)) {
+ foreach ($functions as $function) {
+ // re-assemble args
+ $hookArgs = $args;
+ $hookArgs[] = $value;
+
+ // bind the App object to the hook
+ $newValue = $function->call($this, ...$hookArgs);
+
+ // update value if one was returned
+ if ($newValue !== null) {
+ $value = $newValue;
+ }
+ }
+ }
+
+ return $value;
+ }
+
+ /**
+ * Sets the directory structure
+ *
+ * @param array $roots
+ * @return self
+ */
+ protected function bakeRoots(array $roots = null)
+ {
+ $roots = array_merge(require static::$root . '/config/roots.php', (array)$roots);
+ $this->roots = Ingredients::bake($roots);
+ return $this;
+ }
+
+ /**
+ * Sets the Url structure
+ *
+ * @param array $urls
+ * @return self
+ */
+ protected function bakeUrls(array $urls = null)
+ {
+ // inject the index URL from the config
+ if (isset($this->options['url']) === true) {
+ $urls['index'] = $this->options['url'];
+ }
+
+ $urls = array_merge(require static::$root . '/config/urls.php', (array)$urls);
+ $this->urls = Ingredients::bake($urls);
+ return $this;
+ }
+
+ /**
+ * Returns all available blueprints for this installation
+ *
+ * @param string $type
+ * @return array
+ */
+ public function blueprints(string $type = 'pages'): array
+ {
+ $blueprints = [];
+
+ foreach ($this->extensions('blueprints') as $name => $blueprint) {
+ if (dirname($name) === $type) {
+ $name = basename($name);
+ $blueprints[$name] = $name;
+ }
+ }
+
+ foreach (glob($this->root('blueprints') . '/' . $type . '/*.yml') as $blueprint) {
+ $name = F::name($blueprint);
+ $blueprints[$name] = $name;
+ }
+
+ ksort($blueprints);
+
+ return array_values($blueprints);
+ }
+
+ /**
+ * Calls any Kirby route
+ *
+ * @param string $path
+ * @param string $method
+ * @return mixed
+ */
+ public function call(string $path = null, string $method = null)
+ {
+ $router = $this->router();
+
+ $router::$beforeEach = function ($route, $path, $method) {
+ $this->trigger('route:before', $route, $path, $method);
+ };
+
+ $router::$afterEach = function ($route, $path, $method, $result) {
+ return $this->apply('route:after', $route, $path, $method, $result);
+ };
+
+ return $router->call($path ?? $this->path(), $method ?? $this->request()->method());
+ }
+
+ /**
+ * Returns a specific user-defined collection
+ * by name. All relevant dependencies are
+ * automatically injected
+ *
+ * @param string $name
+ * @return \Kirby\Cms\Collection|null
+ */
+ public function collection(string $name)
+ {
+ return $this->collections()->get($name, [
+ 'kirby' => $this,
+ 'site' => $this->site(),
+ 'pages' => $this->site()->children(),
+ 'users' => $this->users()
+ ]);
+ }
+
+ /**
+ * Returns all user-defined collections
+ *
+ * @return \Kirby\Cms\Collections
+ */
+ public function collections()
+ {
+ return $this->collections = $this->collections ?? new Collections();
+ }
+
+ /**
+ * Returns a core component
+ *
+ * @internal
+ * @param string $name
+ * @return mixed
+ */
+ public function component($name)
+ {
+ return $this->extensions['components'][$name] ?? null;
+ }
+
+ /**
+ * Returns the content extension
+ *
+ * @internal
+ * @return string
+ */
+ public function contentExtension(): string
+ {
+ return $this->options['content']['extension'] ?? 'txt';
+ }
+
+ /**
+ * Returns files that should be ignored when scanning folders
+ *
+ * @internal
+ * @return array
+ */
+ public function contentIgnore(): array
+ {
+ return $this->options['content']['ignore'] ?? Dir::$ignore;
+ }
+
+ /**
+ * Calls a page controller by name
+ * and with the given arguments
+ *
+ * @internal
+ * @param string $name
+ * @param array $arguments
+ * @param string $contentType
+ * @return array
+ */
+ public function controller(string $name, array $arguments = [], string $contentType = 'html'): array
+ {
+ $name = basename(strtolower($name));
+
+ if ($controller = $this->controllerLookup($name, $contentType)) {
+ return (array)$controller->call($this, $arguments);
+ }
+
+ if ($contentType !== 'html') {
+
+ // no luck for a specific representation controller?
+ // let's try the html controller instead
+ if ($controller = $this->controllerLookup($name)) {
+ return (array)$controller->call($this, $arguments);
+ }
+ }
+
+ // still no luck? Let's take the site controller
+ if ($controller = $this->controllerLookup('site')) {
+ return (array)$controller->call($this, $arguments);
+ }
+
+ return [];
+ }
+
+ /**
+ * Try to find a controller by name
+ *
+ * @param string $name
+ * @param string $contentType
+ * @return \Kirby\Toolkit\Controller|null
+ */
+ protected function controllerLookup(string $name, string $contentType = 'html')
+ {
+ if ($contentType !== null && $contentType !== 'html') {
+ $name .= '.' . $contentType;
+ }
+
+ // controller on disk
+ if ($controller = Controller::load($this->root('controllers') . '/' . $name . '.php')) {
+ return $controller;
+ }
+
+ // registry controller
+ if ($controller = $this->extension('controllers', $name)) {
+ return is_a($controller, 'Kirby\Toolkit\Controller') ? $controller : new Controller($controller);
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns the default language object
+ *
+ * @return \Kirby\Cms\Language|null
+ */
+ public function defaultLanguage()
+ {
+ return $this->defaultLanguage = $this->defaultLanguage ?? $this->languages()->default();
+ }
+
+ /**
+ * Destroy the instance singleton and
+ * purge other static props
+ *
+ * @internal
+ */
+ public static function destroy(): void
+ {
+ static::$plugins = [];
+ static::$instance = null;
+ }
+
+ /**
+ * Detect the prefered language from the visitor object
+ *
+ * @return \Kirby\Cms\Language
+ */
+ public function detectedLanguage()
+ {
+ $languages = $this->languages();
+ $visitor = $this->visitor();
+
+ foreach ($visitor->acceptedLanguages() as $lang) {
+ if ($language = $languages->findBy('locale', $lang->locale(LC_ALL))) {
+ return $language;
+ }
+ }
+
+ foreach ($visitor->acceptedLanguages() as $lang) {
+ if ($language = $languages->findBy('code', $lang->code())) {
+ return $language;
+ }
+ }
+
+ return $this->defaultLanguage();
+ }
+
+ /**
+ * Returns the Email singleton
+ *
+ * @param mixed $preset
+ * @param array $props
+ * @return \Kirby\Email\PHPMailer
+ */
+ public function email($preset = [], array $props = [])
+ {
+ return new Emailer((new Email($preset, $props))->toArray(), $props['debug'] ?? false);
+ }
+
+ /**
+ * Finds any file in the content directory
+ *
+ * @param string $path
+ * @param mixed $parent
+ * @param bool $drafts
+ * @return \Kirby\Cms\File|null
+ */
+ public function file(string $path, $parent = null, bool $drafts = true)
+ {
+ $parent = $parent ?? $this->site();
+ $id = dirname($path);
+ $filename = basename($path);
+
+ if (is_a($parent, 'Kirby\Cms\User') === true) {
+ return $parent->file($filename);
+ }
+
+ if (is_a($parent, 'Kirby\Cms\File') === true) {
+ $parent = $parent->parent();
+ }
+
+ if ($id === '.') {
+ if ($file = $parent->file($filename)) {
+ return $file;
+ } elseif ($file = $this->site()->file($filename)) {
+ return $file;
+ } else {
+ return null;
+ }
+ }
+
+ if ($page = $this->page($id, $parent, $drafts)) {
+ return $page->file($filename);
+ }
+
+ if ($page = $this->page($id, null, $drafts)) {
+ return $page->file($filename);
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns the current App instance
+ *
+ * @param \Kirby\Cms\App $instance
+ * @return self
+ */
+ public static function instance(self $instance = null)
+ {
+ if ($instance === null) {
+ return static::$instance ?? new static();
+ }
+
+ return static::$instance = $instance;
+ }
+
+ /**
+ * Takes almost any kind of input and
+ * tries to convert it into a valid response
+ *
+ * @internal
+ * @param mixed $input
+ * @return \Kirby\Http\Response
+ */
+ public function io($input)
+ {
+ // use the current response configuration
+ $response = $this->response();
+
+ // any direct exception will be turned into an error page
+ if (is_a($input, 'Throwable') === true) {
+ if (is_a($input, 'Kirby\Exception\Exception') === true) {
+ $code = $input->getHttpCode();
+ $message = $input->getMessage();
+ } else {
+ $code = $input->getCode();
+ $message = $input->getMessage();
+ }
+
+ if ($code < 400 || $code > 599) {
+ $code = 500;
+ }
+
+ if ($errorPage = $this->site()->errorPage()) {
+ return $response->code($code)->send($errorPage->render([
+ 'errorCode' => $code,
+ 'errorMessage' => $message,
+ 'errorType' => get_class($input)
+ ]));
+ }
+
+ return $response
+ ->code($code)
+ ->type('text/html')
+ ->send($message);
+ }
+
+ // Empty input
+ if (empty($input) === true) {
+ return $this->io(new NotFoundException());
+ }
+
+ // Response Configuration
+ if (is_a($input, 'Kirby\Cms\Responder') === true) {
+ return $input->send();
+ }
+
+ // Responses
+ if (is_a($input, 'Kirby\Http\Response') === true) {
+ return $input;
+ }
+
+ // Pages
+ if (is_a($input, 'Kirby\Cms\Page')) {
+ try {
+ $html = $input->render();
+ } catch (ErrorPageException $e) {
+ return $this->io($e);
+ }
+
+ if ($input->isErrorPage() === true) {
+ if ($response->code() === null) {
+ $response->code(404);
+ }
+ }
+
+ return $response->send($html);
+ }
+
+ // Files
+ if (is_a($input, 'Kirby\Cms\File')) {
+ return $response->redirect($input->mediaUrl(), 307)->send();
+ }
+
+ // Simple HTML response
+ if (is_string($input) === true) {
+ return $response->send($input);
+ }
+
+ // array to json conversion
+ if (is_array($input) === true) {
+ return $response->json($input)->send();
+ }
+
+ throw new InvalidArgumentException('Unexpected input');
+ }
+
+ /**
+ * Renders a single KirbyTag with the given attributes
+ *
+ * @internal
+ * @param string $type
+ * @param string $value
+ * @param array $attr
+ * @param array $data
+ * @return string
+ */
+ public function kirbytag(string $type, string $value = null, array $attr = [], array $data = []): string
+ {
+ $data['kirby'] = $data['kirby'] ?? $this;
+ $data['site'] = $data['site'] ?? $data['kirby']->site();
+ $data['parent'] = $data['parent'] ?? $data['site']->page();
+
+ return (new KirbyTag($type, $value, $attr, $data, $this->options))->render();
+ }
+
+ /**
+ * KirbyTags Parser
+ *
+ * @internal
+ * @param string $text
+ * @param array $data
+ * @return string
+ */
+ public function kirbytags(string $text = null, array $data = []): string
+ {
+ $data['kirby'] = $data['kirby'] ?? $this;
+ $data['site'] = $data['site'] ?? $data['kirby']->site();
+ $data['parent'] = $data['parent'] ?? $data['site']->page();
+
+ return KirbyTags::parse($text, $data, $this->options, $this->extensions['hooks']);
+ }
+
+ /**
+ * Parses KirbyTags first and Markdown afterwards
+ *
+ * @internal
+ * @param string $text
+ * @param array $data
+ * @param bool $inline
+ * @return string
+ */
+ public function kirbytext(string $text = null, array $data = [], bool $inline = false): string
+ {
+ $text = $this->apply('kirbytext:before', $text);
+ $text = $this->kirbytags($text, $data);
+ $text = $this->markdown($text, $inline);
+
+ if ($this->option('smartypants', false) !== false) {
+ $text = $this->smartypants($text);
+ }
+
+ $text = $this->apply('kirbytext:after', $text);
+
+ return $text;
+ }
+
+ /**
+ * Returns the current language
+ *
+ * @param string|null $code
+ * @return \Kirby\Cms\Language|null
+ */
+ public function language(string $code = null)
+ {
+ if ($this->multilang() === false) {
+ return null;
+ }
+
+ if ($code === 'default') {
+ return $this->languages()->default();
+ }
+
+ if ($code !== null) {
+ return $this->languages()->find($code);
+ }
+
+ return $this->language = $this->language ?? $this->languages()->default();
+ }
+
+ /**
+ * Returns the current language code
+ *
+ * @internal
+ * @param string|null $languageCode
+ * @return string|null
+ */
+ public function languageCode(string $languageCode = null): ?string
+ {
+ if ($language = $this->language($languageCode)) {
+ return $language->code();
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns all available site languages
+ *
+ * @return \Kirby\Cms\Languages
+ */
+ public function languages()
+ {
+ if ($this->languages !== null) {
+ return clone $this->languages;
+ }
+
+ return $this->languages = Languages::load();
+ }
+
+ /**
+ * Returns the app's locks object
+ *
+ * @return \Kirby\Cms\ContentLocks
+ */
+ public function locks(): ContentLocks
+ {
+ if ($this->locks !== null) {
+ return $this->locks;
+ }
+
+ return $this->locks = new ContentLocks();
+ }
+
+ /**
+ * Parses Markdown
+ *
+ * @internal
+ * @param string $text
+ * @param bool $inline
+ * @return string
+ */
+ public function markdown(string $text = null, bool $inline = false): string
+ {
+ return $this->component('markdown')($this, $text, $this->options['markdown'] ?? [], $inline);
+ }
+
+ /**
+ * Check for a multilang setup
+ *
+ * @return bool
+ */
+ public function multilang(): bool
+ {
+ if ($this->multilang !== null) {
+ return $this->multilang;
+ }
+
+ return $this->multilang = $this->languages()->count() !== 0;
+ }
+
+ /**
+ * Returns the nonce, which is used
+ * in the panel for inline scripts
+ * @since 3.3.0
+ *
+ * @return string
+ */
+ public function nonce(): string
+ {
+ return $this->nonce = $this->nonce ?? base64_encode(random_bytes(20));
+ }
+
+ /**
+ * Load a specific configuration option
+ *
+ * @param string $key
+ * @param mixed $default
+ * @return mixed
+ */
+ public function option(string $key, $default = null)
+ {
+ return A::get($this->options, $key, $default);
+ }
+
+ /**
+ * Returns all configuration options
+ *
+ * @return array
+ */
+ public function options(): array
+ {
+ return $this->options;
+ }
+
+ /**
+ * Load all options from files in site/config
+ *
+ * @return array
+ */
+ protected function optionsFromConfig(): array
+ {
+ $server = $this->server();
+ $root = $this->root('config');
+
+ Config::$data = [];
+
+ $main = F::load($root . '/config.php', []);
+ $host = F::load($root . '/config.' . basename($server->host()) . '.php', []);
+ $addr = F::load($root . '/config.' . basename($server->address()) . '.php', []);
+
+ $config = Config::$data;
+
+ return $this->options = array_replace_recursive($config, $main, $host, $addr);
+ }
+
+ /**
+ * Inject options from Kirby instance props
+ *
+ * @param array $options
+ * @return array
+ */
+ protected function optionsFromProps(array $options = []): array
+ {
+ return $this->options = array_replace_recursive($this->options, $options);
+ }
+
+ /**
+ * Merge last-minute options from ready callback
+ *
+ * @return array
+ */
+ protected function optionsFromReadyCallback(): array
+ {
+ if (isset($this->options['ready']) === true && is_callable($this->options['ready']) === true) {
+ // fetch last-minute options from the callback
+ $options = (array)$this->options['ready']($this);
+
+ // inject all last-minute options recursively
+ $this->options = array_replace_recursive($this->options, $options);
+ }
+
+ return $this->options;
+ }
+
+ /**
+ * Returns any page from the content folder
+ *
+ * @param string $id|null
+ * @param \Kirby\Cms\Page|\Kirby\Cms\Site|null $parent
+ * @param bool $drafts
+ * @return \Kirby\Cms\Page|null
+ */
+ public function page(?string $id = null, $parent = null, bool $drafts = true)
+ {
+ if ($id === null) {
+ return null;
+ }
+
+ $parent = $parent ?? $this->site();
+
+ if ($page = $parent->find($id)) {
+ return $page;
+ }
+
+ if ($drafts === true && $draft = $parent->draft($id)) {
+ return $draft;
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns the request path
+ *
+ * @return string
+ */
+ public function path(): string
+ {
+ if (is_string($this->path) === true) {
+ return $this->path;
+ }
+
+ $requestUri = '/' . $this->request()->url()->path();
+ $scriptName = $_SERVER['SCRIPT_NAME'];
+ $scriptFile = basename($scriptName);
+ $scriptDir = dirname($scriptName);
+ $scriptPath = $scriptFile === 'index.php' ? $scriptDir : $scriptName;
+ $requestPath = preg_replace('!^' . preg_quote($scriptPath) . '!', '', $requestUri);
+
+ return $this->setPath($requestPath)->path;
+ }
+
+ /**
+ * Returns the Response object for the
+ * current request
+ *
+ * @param string|null $path
+ * @param string|null $method
+ * @return \Kirby\Http\Response
+ */
+ public function render(string $path = null, string $method = null)
+ {
+ return $this->io($this->call($path, $method));
+ }
+
+ /**
+ * Returns the Request singleton
+ *
+ * @return \Kirby\Http\Request
+ */
+ public function request()
+ {
+ return $this->request = $this->request ?? new Request();
+ }
+
+ /**
+ * Path resolver for the router
+ *
+ * @internal
+ * @param string $path
+ * @param string|null $language
+ * @return mixed
+ */
+ public function resolve(string $path = null, string $language = null)
+ {
+ // set the current translation
+ $this->setCurrentTranslation($language);
+
+ // set the current locale
+ $this->setCurrentLanguage($language);
+
+ // the site is needed a couple times here
+ $site = $this->site();
+
+ // use the home page
+ if ($path === null) {
+ if ($homePage = $site->homePage()) {
+ return $homePage;
+ }
+
+ throw new NotFoundException('The home page does not exist');
+ }
+
+ // search for the page by path
+ $page = $site->find($path);
+
+ // search for a draft if the page cannot be found
+ if (!$page && $draft = $site->draft($path)) {
+ if ($this->user() || $draft->isVerified(get('token'))) {
+ $page = $draft;
+ }
+ }
+
+ // try to resolve content representations if the path has an extension
+ $extension = F::extension($path);
+
+ // no content representation? then return the page
+ if (empty($extension) === true) {
+ return $page;
+ }
+
+ // only try to return a representation
+ // when the page has been found
+ if ($page) {
+ try {
+ return $this
+ ->response()
+ ->body($page->render([], $extension))
+ ->type($extension);
+ } catch (NotFoundException $e) {
+ return null;
+ }
+ }
+
+ $id = dirname($path);
+ $filename = basename($path);
+
+ // try to resolve image urls for pages and drafts
+ if ($page = $site->findPageOrDraft($id)) {
+ return $page->file($filename);
+ }
+
+ // try to resolve site files at least
+ return $site->file($filename);
+ }
+
+ /**
+ * Response configuration
+ *
+ * @return \Kirby\Cms\Responder
+ */
+ public function response()
+ {
+ return $this->response = $this->response ?? new Responder();
+ }
+
+ /**
+ * Returns all user roles
+ *
+ * @return \Kirby\Cms\Roles
+ */
+ public function roles()
+ {
+ return $this->roles = $this->roles ?? Roles::load($this->root('roles'));
+ }
+
+ /**
+ * Returns a system root
+ *
+ * @param string $type
+ * @return string
+ */
+ public function root(string $type = 'index'): string
+ {
+ return $this->roots->__get($type);
+ }
+
+ /**
+ * Returns the directory structure
+ *
+ * @return \Kirby\Cms\Ingredients
+ */
+ public function roots()
+ {
+ return $this->roots;
+ }
+
+ /**
+ * Returns the currently active route
+ *
+ * @return \Kirby\Http\Route|null
+ */
+ public function route()
+ {
+ return $this->router()->route();
+ }
+
+ /**
+ * Returns the Router singleton
+ *
+ * @internal
+ * @return \Kirby\Http\Router
+ */
+ public function router()
+ {
+ $routes = $this->routes();
+
+ if ($this->multilang() === true) {
+ foreach ($routes as $index => $route) {
+ if (empty($route['language']) === false) {
+ unset($routes[$index]);
+ }
+ }
+ }
+
+ return $this->router = $this->router ?? new Router($routes);
+ }
+
+ /**
+ * Returns all defined routes
+ *
+ * @internal
+ * @return array
+ */
+ public function routes(): array
+ {
+ if (is_array($this->routes) === true) {
+ return $this->routes;
+ }
+
+ $registry = $this->extensions('routes');
+ $system = (include static::$root . '/config/routes.php')($this);
+ $routes = array_merge($system['before'], $registry, $system['after']);
+
+ return $this->routes = $routes;
+ }
+
+ /**
+ * Returns the current session object
+ *
+ * @param array $options Additional options, see the session component
+ * @return \Kirby\Session\Session
+ */
+ public function session(array $options = [])
+ {
+ return $this->sessionHandler()->get($options);
+ }
+
+ /**
+ * Returns the session handler
+ *
+ * @return \Kirby\Session\AutoSession
+ */
+ public function sessionHandler()
+ {
+ $this->sessionHandler = $this->sessionHandler ?? new AutoSession($this->root('sessions'), $this->option('session', []));
+ return $this->sessionHandler;
+ }
+
+ /**
+ * Create your own set of languages
+ *
+ * @param array $languages
+ * @return self
+ */
+ protected function setLanguages(array $languages = null)
+ {
+ if ($languages !== null) {
+ $objects = [];
+
+ foreach ($languages as $props) {
+ $objects[] = new Language($props);
+ }
+
+ $this->languages = new Languages($objects);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Sets the request path that is
+ * used for the router
+ *
+ * @param string $path
+ * @return self
+ */
+ protected function setPath(string $path = null)
+ {
+ $this->path = $path !== null ? trim($path, '/') : null;
+ return $this;
+ }
+
+ /**
+ * Sets the request
+ *
+ * @param array $request
+ * @return self
+ */
+ protected function setRequest(array $request = null)
+ {
+ if ($request !== null) {
+ $this->request = new Request($request);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Create your own set of roles
+ *
+ * @param array $roles
+ * @return self
+ */
+ protected function setRoles(array $roles = null)
+ {
+ if ($roles !== null) {
+ $this->roles = Roles::factory($roles, [
+ 'kirby' => $this
+ ]);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Sets a custom Site object
+ *
+ * @param \Kirby\Cms\Site|array $site
+ * @return self
+ */
+ protected function setSite($site = null)
+ {
+ if (is_array($site) === true) {
+ $site = new Site($site + [
+ 'kirby' => $this
+ ]);
+ }
+
+ $this->site = $site;
+ return $this;
+ }
+
+ /**
+ * Returns the Server object
+ *
+ * @return \Kirby\Http\Server
+ */
+ public function server()
+ {
+ return $this->server = $this->server ?? new Server();
+ }
+
+ /**
+ * Initializes and returns the Site object
+ *
+ * @return \Kirby\Cms\Site
+ */
+ public function site()
+ {
+ return $this->site = $this->site ?? new Site([
+ 'errorPageId' => $this->options['error'] ?? 'error',
+ 'homePageId' => $this->options['home'] ?? 'home',
+ 'kirby' => $this,
+ 'url' => $this->url('index'),
+ ]);
+ }
+
+ /**
+ * Applies the smartypants rule on the text
+ *
+ * @internal
+ * @param string $text
+ * @return string
+ */
+ public function smartypants(string $text = null): string
+ {
+ $options = $this->option('smartypants', []);
+
+ if ($options === false) {
+ return $text;
+ } elseif (is_array($options) === false) {
+ $options = [];
+ }
+
+ if ($this->multilang() === true) {
+ $languageSmartypants = $this->language()->smartypants() ?? [];
+
+ if (empty($languageSmartypants) === false) {
+ $options = array_merge($options, $languageSmartypants);
+ }
+ }
+
+ return $this->component('smartypants')($this, $text, $options);
+ }
+
+ /**
+ * Uses the snippet component to create
+ * and return a template snippet
+ *
+ * @internal
+ * @param mixed $name
+ * @param array $data
+ * @return string|null
+ */
+ public function snippet($name, array $data = []): ?string
+ {
+ return $this->component('snippet')($this, $name, array_merge($this->data, $data));
+ }
+
+ /**
+ * System check class
+ *
+ * @return \Kirby\Cms\System
+ */
+ public function system()
+ {
+ return $this->system = $this->system ?? new System($this);
+ }
+
+ /**
+ * Uses the template component to initialize
+ * and return the Template object
+ *
+ * @internal
+ * @return \Kirby\Cms\Template
+ * @param string $name
+ * @param string $type
+ * @param string $defaultType
+ */
+ public function template(string $name, string $type = 'html', string $defaultType = 'html')
+ {
+ return $this->component('template')($this, $name, $type, $defaultType);
+ }
+
+ /**
+ * Thumbnail creator
+ *
+ * @param string $src
+ * @param string $dst
+ * @param array $options
+ * @return string
+ */
+ public function thumb(string $src, string $dst, array $options = []): string
+ {
+ return $this->component('thumb')($this, $src, $dst, $options);
+ }
+
+ /**
+ * Trigger a hook by name
+ *
+ * @internal
+ * @param string $name
+ * @param mixed ...$arguments
+ * @return void
+ */
+ public function trigger(string $name, ...$arguments)
+ {
+ if ($functions = $this->extension('hooks', $name)) {
+ static $level = 0;
+ static $triggered = [];
+ $level++;
+
+ foreach ($functions as $index => $function) {
+ if (in_array($function, $triggered[$name] ?? []) === true) {
+ continue;
+ }
+
+ // mark the hook as triggered, to avoid endless loops
+ $triggered[$name][] = $function;
+
+ // bind the App object to the hook
+ $function->call($this, ...$arguments);
+ }
+
+ $level--;
+
+ if ($level === 0) {
+ $triggered = [];
+ }
+ }
+ }
+
+ /**
+ * Returns a system url
+ *
+ * @param string $type
+ * @return string
+ */
+ public function url(string $type = 'index'): string
+ {
+ return $this->urls->__get($type);
+ }
+
+ /**
+ * Returns the url structure
+ *
+ * @return \Kirby\Cms\Ingredients
+ */
+ public function urls()
+ {
+ return $this->urls;
+ }
+
+ /**
+ * Returns the current version number from
+ * the composer.json (Keep that up to date! :))
+ *
+ * @return string|null
+ */
+ public static function version(): ?string
+ {
+ return static::$version = static::$version ?? Data::read(static::$root . '/composer.json')['version'] ?? null;
+ }
+
+ /**
+ * Creates a hash of the version number
+ *
+ * @return string
+ */
+ public static function versionHash(): string
+ {
+ return md5(static::version());
+ }
+
+ /**
+ * Returns the visitor object
+ *
+ * @return \Kirby\Cms\Visitor
+ */
+ public function visitor()
+ {
+ return $this->visitor = $this->visitor ?? new Visitor();
+ }
+}
diff --git a/kirby/src/Cms/AppCaches.php b/kirby/src/Cms/AppCaches.php
new file mode 100755
index 0000000..862bc38
--- /dev/null
+++ b/kirby/src/Cms/AppCaches.php
@@ -0,0 +1,138 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://getkirby.com/license
+ */
+trait AppCaches
+{
+ protected $caches = [];
+
+ /**
+ * Returns a cache instance by key
+ *
+ * @param string $key
+ * @return \Kirby\Cache\Cache
+ */
+ public function cache(string $key)
+ {
+ if (isset($this->caches[$key]) === true) {
+ return $this->caches[$key];
+ }
+
+ // get the options for this cache type
+ $options = $this->cacheOptions($key);
+
+ if ($options['active'] === false) {
+ // use a dummy cache that does nothing
+ return $this->caches[$key] = new NullCache();
+ }
+
+ $type = strtolower($options['type']);
+ $types = $this->extensions['cacheTypes'] ?? [];
+
+ if (array_key_exists($type, $types) === false) {
+ throw new InvalidArgumentException([
+ 'key' => 'app.invalid.cacheType',
+ 'data' => ['type' => $type]
+ ]);
+ }
+
+ $className = $types[$type];
+
+ // initialize the cache class
+ $cache = new $className($options);
+
+ // check if it is a useable cache object
+ if (is_a($cache, 'Kirby\Cache\Cache') !== true) {
+ throw new InvalidArgumentException([
+ 'key' => 'app.invalid.cacheType',
+ 'data' => ['type' => $type]
+ ]);
+ }
+
+ return $this->caches[$key] = $cache;
+ }
+
+ /**
+ * Returns the cache options by key
+ *
+ * @param string $key
+ * @return array
+ */
+ protected function cacheOptions(string $key): array
+ {
+ $options = $this->option($cacheKey = $this->cacheOptionsKey($key), false);
+
+ if ($options === false) {
+ return [
+ 'active' => false
+ ];
+ }
+
+ $prefix = str_replace(['/', ':'], '_', $this->system()->indexUrl()) .
+ '/' .
+ str_replace('.', '/', $key);
+
+ $defaults = [
+ 'active' => true,
+ 'type' => 'file',
+ 'extension' => 'cache',
+ 'root' => $this->root('cache'),
+ 'prefix' => $prefix
+ ];
+
+ if ($options === true) {
+ return $defaults;
+ } else {
+ return array_merge($defaults, $options);
+ }
+ }
+
+ /**
+ * Takes care of converting prefixed plugin cache setups
+ * to the right cache key, while leaving regular cache
+ * setups untouched.
+ *
+ * @param string $key
+ * @return string
+ */
+ protected function cacheOptionsKey(string $key): string
+ {
+ $prefixedKey = 'cache.' . $key;
+
+ if (isset($this->options[$prefixedKey])) {
+ return $prefixedKey;
+ }
+
+ // plain keys without dots don't need further investigation
+ // since they can never be from a plugin.
+ if (strpos($key, '.') === false) {
+ return $prefixedKey;
+ }
+
+ // try to extract the plugin name
+ $parts = explode('.', $key);
+ $pluginName = implode('/', array_slice($parts, 0, 2));
+ $pluginPrefix = implode('.', array_slice($parts, 0, 2));
+ $cacheName = implode('.', array_slice($parts, 2));
+
+ // check if such a plugin exists
+ if ($this->plugin($pluginName)) {
+ return empty($cacheName) === true ? $pluginPrefix . '.cache' : $pluginPrefix . '.cache.' . $cacheName;
+ }
+
+ return $prefixedKey;
+ }
+}
diff --git a/kirby/src/Cms/AppErrors.php b/kirby/src/Cms/AppErrors.php
new file mode 100755
index 0000000..6936d0d
--- /dev/null
+++ b/kirby/src/Cms/AppErrors.php
@@ -0,0 +1,118 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://getkirby.com/license
+ */
+trait AppErrors
+{
+ protected function handleCliErrors(): void
+ {
+ $whoops = new Whoops();
+ $whoops->pushHandler(new PlainTextHandler());
+ $whoops->register();
+ }
+
+ protected function handleErrors()
+ {
+ if ($this->request()->cli() === true) {
+ $this->handleCliErrors();
+ return;
+ }
+
+ if ($this->visitor()->prefersJson() === true) {
+ $this->handleJsonErrors();
+ return;
+ }
+
+ $this->handleHtmlErrors();
+ }
+
+ protected function handleHtmlErrors()
+ {
+ $whoops = new Whoops();
+
+ if ($this->option('debug') === true) {
+ if ($this->option('whoops', true) === true) {
+ $handler = new PrettyPageHandler();
+ $handler->setPageTitle('Kirby CMS Debugger');
+
+ if ($editor = $this->option('editor')) {
+ $handler->setEditor($editor);
+ }
+
+ $whoops->pushHandler($handler);
+ $whoops->register();
+ }
+ } else {
+ $handler = new CallbackHandler(function ($exception, $inspector, $run) {
+ $fatal = $this->option('fatal');
+
+ if (is_a($fatal, 'Closure') === true) {
+ echo $fatal($this);
+ } else {
+ include static::$root . '/views/fatal.php';
+ }
+
+ return Handler::QUIT;
+ });
+
+ $whoops->pushHandler($handler);
+ $whoops->register();
+ }
+ }
+
+ protected function handleJsonErrors()
+ {
+ $whoops = new Whoops();
+ $handler = new CallbackHandler(function ($exception, $inspector, $run) {
+ if (is_a($exception, 'Kirby\Exception\Exception') === true) {
+ $httpCode = $exception->getHttpCode();
+ $code = $exception->getCode();
+ $details = $exception->getDetails();
+ } else {
+ $httpCode = 500;
+ $code = $exception->getCode();
+ $details = null;
+ }
+
+ if ($this->option('debug') === true) {
+ echo Response::json([
+ 'status' => 'error',
+ 'exception' => get_class($exception),
+ 'code' => $code,
+ 'message' => $exception->getMessage(),
+ 'details' => $details,
+ 'file' => ltrim($exception->getFile(), $_SERVER['DOCUMENT_ROOT'] ?? null),
+ 'line' => $exception->getLine(),
+ ], $httpCode);
+ } else {
+ echo Response::json([
+ 'status' => 'error',
+ 'code' => $code,
+ 'details' => $details,
+ 'message' => 'An unexpected error occurred! Enable debug mode for more info: https://getkirby.com/docs/reference/system/options/debug',
+ ], $httpCode);
+ }
+
+ return Handler::QUIT;
+ });
+
+ $whoops->pushHandler($handler);
+ $whoops->register();
+ }
+}
diff --git a/kirby/src/Cms/AppPlugins.php b/kirby/src/Cms/AppPlugins.php
new file mode 100755
index 0000000..121e4b1
--- /dev/null
+++ b/kirby/src/Cms/AppPlugins.php
@@ -0,0 +1,743 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://getkirby.com/license
+ */
+trait AppPlugins
+{
+ /**
+ * A list of all registered plugins
+ *
+ * @var array
+ */
+ protected static $plugins = [];
+
+ /**
+ * The extension registry
+ *
+ * @var array
+ */
+ protected $extensions = [
+ // load options first to make them available for the rest
+ 'options' => [],
+
+ // other plugin types
+ 'api' => [],
+ 'blueprints' => [],
+ 'cacheTypes' => [],
+ 'collections' => [],
+ 'components' => [],
+ 'controllers' => [],
+ 'collectionFilters' => [],
+ 'fieldMethods' => [],
+ 'fileMethods' => [],
+ 'filesMethods' => [],
+ 'fields' => [],
+ 'hooks' => [],
+ 'pages' => [],
+ 'pageMethods' => [],
+ 'pagesMethods' => [],
+ 'pageModels' => [],
+ 'routes' => [],
+ 'sections' => [],
+ 'siteMethods' => [],
+ 'snippets' => [],
+ 'tags' => [],
+ 'templates' => [],
+ 'translations' => [],
+ 'userMethods' => [],
+ 'userModels' => [],
+ 'usersMethods' => [],
+ 'validators' => []
+ ];
+
+ /**
+ * Cache for system extensions
+ *
+ * @var array
+ */
+ protected static $systemExtensions = null;
+
+ /**
+ * Flag when plugins have been loaded
+ * to not load them again
+ *
+ * @var bool
+ */
+ protected $pluginsAreLoaded = false;
+
+ /**
+ * Register all given extensions
+ *
+ * @internal
+ * @param array $extensions
+ * @param \Kirby\Cms\Plugin $plugin The plugin which defined those extensions
+ * @return array
+ */
+ public function extend(array $extensions, Plugin $plugin = null): array
+ {
+ foreach ($this->extensions as $type => $registered) {
+ if (isset($extensions[$type]) === true) {
+ $this->{'extend' . $type}($extensions[$type], $plugin);
+ }
+ }
+
+ return $this->extensions;
+ }
+
+ /**
+ * Registers API extensions
+ *
+ * @param array|bool $api
+ * @return array
+ */
+ protected function extendApi($api): array
+ {
+ if (is_array($api) === true) {
+ if (is_a($api['routes'] ?? [], 'Closure') === true) {
+ $api['routes'] = $api['routes']($this);
+ }
+
+ return $this->extensions['api'] = A::merge($this->extensions['api'], $api, A::MERGE_APPEND);
+ } else {
+ return $this->extensions['api'];
+ }
+ }
+
+ /**
+ * Registers additional blueprints
+ *
+ * @param array $blueprints
+ * @return array
+ */
+ protected function extendBlueprints(array $blueprints): array
+ {
+ return $this->extensions['blueprints'] = array_merge($this->extensions['blueprints'], $blueprints);
+ }
+
+ /**
+ * Registers additional cache types
+ *
+ * @param array $cacheTypes
+ * @return array
+ */
+ protected function extendCacheTypes(array $cacheTypes): array
+ {
+ return $this->extensions['cacheTypes'] = array_merge($this->extensions['cacheTypes'], $cacheTypes);
+ }
+
+ /**
+ * Registers additional collection filters
+ *
+ * @param array $filters
+ * @return array
+ */
+ protected function extendCollectionFilters(array $filters): array
+ {
+ return $this->extensions['collectionFilters'] = Collection::$filters = array_merge(Collection::$filters, $filters);
+ }
+
+ /**
+ * Registers additional collections
+ *
+ * @param array $collections
+ * @return array
+ */
+ protected function extendCollections(array $collections): array
+ {
+ return $this->extensions['collections'] = array_merge($this->extensions['collections'], $collections);
+ }
+
+ /**
+ * Registers core components
+ *
+ * @param array $components
+ * @return array
+ */
+ protected function extendComponents(array $components): array
+ {
+ return $this->extensions['components'] = array_merge($this->extensions['components'], $components);
+ }
+
+ /**
+ * Registers additional controllers
+ *
+ * @param array $controllers
+ * @return array
+ */
+ protected function extendControllers(array $controllers): array
+ {
+ return $this->extensions['controllers'] = array_merge($this->extensions['controllers'], $controllers);
+ }
+
+ /**
+ * Registers additional file methods
+ *
+ * @param array $methods
+ * @return array
+ */
+ protected function extendFileMethods(array $methods): array
+ {
+ return $this->extensions['fileMethods'] = File::$methods = array_merge(File::$methods, $methods);
+ }
+
+ /**
+ * Registers additional files methods
+ *
+ * @param array $methods
+ * @return array
+ */
+ protected function extendFilesMethods(array $methods): array
+ {
+ return $this->extensions['filesMethods'] = Files::$methods = array_merge(Files::$methods, $methods);
+ }
+
+ /**
+ * Registers additional field methods
+ *
+ * @param array $methods
+ * @return array
+ */
+ protected function extendFieldMethods(array $methods): array
+ {
+ return $this->extensions['fieldMethods'] = Field::$methods = array_merge(Field::$methods, array_change_key_case($methods));
+ }
+
+ /**
+ * Registers Panel fields
+ *
+ * @param array $fields
+ * @return array
+ */
+ protected function extendFields(array $fields): array
+ {
+ return $this->extensions['fields'] = FormField::$types = array_merge(FormField::$types, $fields);
+ }
+
+ /**
+ * Registers hooks
+ *
+ * @param array $hooks
+ * @return array
+ */
+ protected function extendHooks(array $hooks): array
+ {
+ foreach ($hooks as $name => $callbacks) {
+ if (isset($this->extensions['hooks'][$name]) === false) {
+ $this->extensions['hooks'][$name] = [];
+ }
+
+ if (is_array($callbacks) === false) {
+ $callbacks = [$callbacks];
+ }
+
+ foreach ($callbacks as $callback) {
+ $this->extensions['hooks'][$name][] = $callback;
+ }
+ }
+
+ return $this->extensions['hooks'];
+ }
+
+ /**
+ * Registers markdown component
+ *
+ * @param Closure $markdown
+ * @return Closure
+ */
+ protected function extendMarkdown(Closure $markdown)
+ {
+ return $this->extensions['markdown'] = $markdown;
+ }
+
+ /**
+ * Registers additional options
+ *
+ * @param array $options
+ * @param \Kirby\Cms\Plugin|null $plugin
+ * @return array
+ */
+ protected function extendOptions(array $options, Plugin $plugin = null): array
+ {
+ if ($plugin !== null) {
+ $prefixed = [];
+
+ foreach ($options as $key => $value) {
+ $prefixed[$plugin->prefix() . '.' . $key] = $value;
+ }
+
+ $options = $prefixed;
+ }
+
+ return $this->extensions['options'] = $this->options = A::merge($options, $this->options, A::MERGE_REPLACE);
+ }
+
+ /**
+ * Registers additional page methods
+ *
+ * @param array $methods
+ * @return array
+ */
+ protected function extendPageMethods(array $methods): array
+ {
+ return $this->extensions['pageMethods'] = Page::$methods = array_merge(Page::$methods, $methods);
+ }
+
+ /**
+ * Registers additional pages methods
+ *
+ * @param array $methods
+ * @return array
+ */
+ protected function extendPagesMethods(array $methods): array
+ {
+ return $this->extensions['pagesMethods'] = Pages::$methods = array_merge(Pages::$methods, $methods);
+ }
+
+ /**
+ * Registers additional page models
+ *
+ * @param array $models
+ * @return array
+ */
+ protected function extendPageModels(array $models): array
+ {
+ return $this->extensions['pageModels'] = Page::$models = array_merge(Page::$models, $models);
+ }
+
+ /**
+ * Registers pages
+ *
+ * @param array $pages
+ * @return array
+ */
+ protected function extendPages(array $pages): array
+ {
+ return $this->extensions['pages'] = array_merge($this->extensions['pages'], $pages);
+ }
+
+ /**
+ * Registers additional routes
+ *
+ * @param array|Closure $routes
+ * @return array
+ */
+ protected function extendRoutes($routes): array
+ {
+ if (is_a($routes, 'Closure') === true) {
+ $routes = $routes($this);
+ }
+
+ return $this->extensions['routes'] = array_merge($this->extensions['routes'], $routes);
+ }
+
+ /**
+ * Registers Panel sections
+ *
+ * @param array $sections
+ * @return array
+ */
+ protected function extendSections(array $sections): array
+ {
+ return $this->extensions['sections'] = Section::$types = array_merge(Section::$types, $sections);
+ }
+
+ /**
+ * Registers additional site methods
+ *
+ * @param array $methods
+ * @return array
+ */
+ protected function extendSiteMethods(array $methods): array
+ {
+ return $this->extensions['siteMethods'] = Site::$methods = array_merge(Site::$methods, $methods);
+ }
+
+ /**
+ * Registers SmartyPants component
+ *
+ * @param Closure $smartypants
+ * @return Closure
+ */
+ protected function extendSmartypants(Closure $smartypants)
+ {
+ return $this->extensions['smartypants'] = $smartypants;
+ }
+
+ /**
+ * Registers additional snippets
+ *
+ * @param array $snippets
+ * @return array
+ */
+ protected function extendSnippets(array $snippets): array
+ {
+ return $this->extensions['snippets'] = array_merge($this->extensions['snippets'], $snippets);
+ }
+
+ /**
+ * Registers additional KirbyTags
+ *
+ * @param array $tags
+ * @return array
+ */
+ protected function extendTags(array $tags): array
+ {
+ return $this->extensions['tags'] = KirbyTag::$types = array_merge(KirbyTag::$types, array_change_key_case($tags));
+ }
+
+ /**
+ * Registers additional templates
+ *
+ * @param array $templates
+ * @return array
+ */
+ protected function extendTemplates(array $templates): array
+ {
+ return $this->extensions['templates'] = array_merge($this->extensions['templates'], $templates);
+ }
+
+ /**
+ * Registers translations
+ *
+ * @param array $translations
+ * @return array
+ */
+ protected function extendTranslations(array $translations): array
+ {
+ return $this->extensions['translations'] = array_replace_recursive($this->extensions['translations'], $translations);
+ }
+
+ /**
+ * Registers additional user methods
+ *
+ * @param array $methods
+ * @return array
+ */
+ protected function extendUserMethods(array $methods): array
+ {
+ return $this->extensions['userMethods'] = User::$methods = array_merge(User::$methods, $methods);
+ }
+
+ /**
+ * Registers additional user models
+ *
+ * @param array $models
+ * @return array
+ */
+ protected function extendUserModels(array $models): array
+ {
+ return $this->extensions['userModels'] = User::$models = array_merge(User::$models, $models);
+ }
+
+ /**
+ * Registers additional users methods
+ *
+ * @param array $methods
+ * @return array
+ */
+ protected function extendUsersMethods(array $methods): array
+ {
+ return $this->extensions['usersMethods'] = Users::$methods = array_merge(Users::$methods, $methods);
+ }
+
+ /**
+ * Registers additional custom validators
+ *
+ * @param array $validators
+ * @return array
+ */
+ protected function extendValidators(array $validators): array
+ {
+ return $this->extensions['validators'] = V::$validators = array_merge(V::$validators, $validators);
+ }
+
+ /**
+ * Returns a given extension by type and name
+ *
+ * @internal
+ * @param string $type i.e. `'hooks'`
+ * @param string $name i.e. `'page.delete:before'`
+ * @param mixed $fallback
+ * @return mixed
+ */
+ public function extension(string $type, string $name, $fallback = null)
+ {
+ return $this->extensions($type)[$name] ?? $fallback;
+ }
+
+ /**
+ * Returns the extensions registry
+ *
+ * @internal
+ * @param string|null $type
+ * @return array
+ */
+ public function extensions(string $type = null)
+ {
+ if ($type === null) {
+ return $this->extensions;
+ }
+
+ return $this->extensions[$type] ?? [];
+ }
+
+ /**
+ * Load extensions from site folders.
+ * This is only used for models for now, but
+ * could be extended later
+ */
+ protected function extensionsFromFolders()
+ {
+ $models = [];
+
+ foreach (glob($this->root('models') . '/*.php') as $model) {
+ $name = F::name($model);
+ $class = str_replace(['.', '-', '_'], '', $name) . 'Page';
+
+ // load the model class
+ include_once $model;
+
+ if (class_exists($class) === true) {
+ $models[$name] = $class;
+ }
+ }
+
+ $this->extendPageModels($models);
+ }
+
+ /**
+ * Register extensions that could be located in
+ * the options array. I.e. hooks and routes can be
+ * setup from the config.
+ *
+ * @return array
+ */
+ protected function extensionsFromOptions()
+ {
+ // register routes and hooks from options
+ $this->extend([
+ 'api' => $this->options['api'] ?? [],
+ 'routes' => $this->options['routes'] ?? [],
+ 'hooks' => $this->options['hooks'] ?? []
+ ]);
+ }
+
+ /**
+ * Apply all plugin extensions
+ *
+ * @param array $plugins
+ * @return void
+ */
+ protected function extensionsFromPlugins()
+ {
+ // register all their extensions
+ foreach ($this->plugins() as $plugin) {
+ $extends = $plugin->extends();
+
+ if (empty($extends) === false) {
+ $this->extend($extends, $plugin);
+ }
+ }
+ }
+
+ /**
+ * Apply all passed extensions
+ *
+ * @param array $props
+ * @return void
+ */
+ protected function extensionsFromProps(array $props)
+ {
+ $this->extend($props);
+ }
+
+ /**
+ * Apply all default extensions
+ *
+ * @return void
+ */
+ protected function extensionsFromSystem()
+ {
+ // load static extensions only once
+ if (static::$systemExtensions === null) {
+ // Form Field Mixins
+ FormField::$mixins['filepicker'] = include static::$root . '/config/fields/mixins/filepicker.php';
+ FormField::$mixins['min'] = include static::$root . '/config/fields/mixins/min.php';
+ FormField::$mixins['options'] = include static::$root . '/config/fields/mixins/options.php';
+ FormField::$mixins['pagepicker'] = include static::$root . '/config/fields/mixins/pagepicker.php';
+ FormField::$mixins['picker'] = include static::$root . '/config/fields/mixins/picker.php';
+ FormField::$mixins['upload'] = include static::$root . '/config/fields/mixins/upload.php';
+ FormField::$mixins['userpicker'] = include static::$root . '/config/fields/mixins/userpicker.php';
+
+ // Tag Aliases
+ KirbyTag::$aliases = [
+ 'youtube' => 'video',
+ 'vimeo' => 'video'
+ ];
+
+ // Field method aliases
+ Field::$aliases = [
+ 'bool' => 'toBool',
+ 'esc' => 'escape',
+ 'excerpt' => 'toExcerpt',
+ 'float' => 'toFloat',
+ 'h' => 'html',
+ 'int' => 'toInt',
+ 'kt' => 'kirbytext',
+ 'kti' => 'kirbytextinline',
+ 'link' => 'toLink',
+ 'md' => 'markdown',
+ 'sp' => 'smartypants',
+ 'v' => 'isValid',
+ 'x' => 'xml'
+ ];
+
+ // blueprint presets
+ PageBlueprint::$presets['pages'] = include static::$root . '/config/presets/pages.php';
+ PageBlueprint::$presets['page'] = include static::$root . '/config/presets/page.php';
+ PageBlueprint::$presets['files'] = include static::$root . '/config/presets/files.php';
+
+ // section mixins
+ Section::$mixins['empty'] = include static::$root . '/config/sections/mixins/empty.php';
+ Section::$mixins['headline'] = include static::$root . '/config/sections/mixins/headline.php';
+ Section::$mixins['help'] = include static::$root . '/config/sections/mixins/help.php';
+ Section::$mixins['layout'] = include static::$root . '/config/sections/mixins/layout.php';
+ Section::$mixins['max'] = include static::$root . '/config/sections/mixins/max.php';
+ Section::$mixins['min'] = include static::$root . '/config/sections/mixins/min.php';
+ Section::$mixins['pagination'] = include static::$root . '/config/sections/mixins/pagination.php';
+ Section::$mixins['parent'] = include static::$root . '/config/sections/mixins/parent.php';
+
+ // section types
+ Section::$types['info'] = include static::$root . '/config/sections/info.php';
+ Section::$types['pages'] = include static::$root . '/config/sections/pages.php';
+ Section::$types['files'] = include static::$root . '/config/sections/files.php';
+ Section::$types['fields'] = include static::$root . '/config/sections/fields.php';
+
+ static::$systemExtensions = [
+ 'components' => include static::$root . '/config/components.php',
+ 'blueprints' => include static::$root . '/config/blueprints.php',
+ 'fields' => include static::$root . '/config/fields.php',
+ 'fieldMethods' => include static::$root . '/config/methods.php',
+ 'tags' => include static::$root . '/config/tags.php'
+ ];
+ }
+
+ // default cache types
+ $this->extendCacheTypes([
+ 'apcu' => 'Kirby\Cache\ApcuCache',
+ 'file' => 'Kirby\Cache\FileCache',
+ 'memcached' => 'Kirby\Cache\MemCached',
+ 'memory' => 'Kirby\Cache\MemoryCache',
+ ]);
+
+ $this->extendComponents(static::$systemExtensions['components']);
+ $this->extendBlueprints(static::$systemExtensions['blueprints']);
+ $this->extendFields(static::$systemExtensions['fields']);
+ $this->extendFieldMethods((static::$systemExtensions['fieldMethods'])($this));
+ $this->extendTags(static::$systemExtensions['tags']);
+ }
+
+ /**
+ * Kirby plugin factory and getter
+ *
+ * @param string $name
+ * @param array|null $extends If null is passed it will be used as getter. Otherwise as factory.
+ * @return \Kirby\Cms\Plugin|null
+ */
+ public static function plugin(string $name, array $extends = null)
+ {
+ if ($extends === null) {
+ return static::$plugins[$name] ?? null;
+ }
+
+ // get the correct root for the plugin
+ $extends['root'] = $extends['root'] ?? dirname(debug_backtrace()[0]['file']);
+
+ $plugin = new Plugin($name, $extends);
+ $name = $plugin->name();
+
+ if (isset(static::$plugins[$name]) === true) {
+ throw new DuplicateException('The plugin "' . $name . '" has already been registered');
+ }
+
+ return static::$plugins[$name] = $plugin;
+ }
+
+ /**
+ * Loads and returns all plugins in the site/plugins directory
+ * Loading only happens on the first call.
+ *
+ * @internal
+ * @param array $plugins Can be used to overwrite the plugins registry
+ * @return array
+ */
+ public function plugins(array $plugins = null): array
+ {
+ // overwrite the existing plugins registry
+ if ($plugins !== null) {
+ $this->pluginsAreLoaded = true;
+ return static::$plugins = $plugins;
+ }
+
+ // don't load plugins twice
+ if ($this->pluginsAreLoaded === true) {
+ return static::$plugins;
+ }
+
+ // load all plugins from site/plugins
+ $this->pluginsLoader();
+
+ // mark plugins as loaded to stop doing it twice
+ $this->pluginsAreLoaded = true;
+ return static::$plugins;
+ }
+
+ /**
+ * Loads all plugins from site/plugins
+ *
+ * @return array Array of loaded directories
+ */
+ protected function pluginsLoader(): array
+ {
+ $root = $this->root('plugins');
+ $loaded = [];
+
+ foreach (Dir::read($root) as $dirname) {
+ if (in_array(substr($dirname, 0, 1), ['.', '_']) === true) {
+ continue;
+ }
+
+ $dir = $root . '/' . $dirname;
+ $entry = $dir . '/index.php';
+
+ if (is_dir($dir) !== true || is_file($entry) !== true) {
+ continue;
+ }
+
+ include_once $entry;
+
+ $loaded[] = $dir;
+ }
+
+ return $loaded;
+ }
+}
diff --git a/kirby/src/Cms/AppTranslations.php b/kirby/src/Cms/AppTranslations.php
new file mode 100755
index 0000000..073addf
--- /dev/null
+++ b/kirby/src/Cms/AppTranslations.php
@@ -0,0 +1,177 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://getkirby.com/license
+ */
+trait AppTranslations
+{
+ protected $translations;
+
+ /**
+ * Setup internationalization
+ *
+ * @return void
+ */
+ protected function i18n(): void
+ {
+ I18n::$load = function ($locale): array {
+ $data = [];
+
+ if ($translation = $this->translation($locale)) {
+ $data = $translation->data();
+ }
+
+ // inject translations from the current language
+ if ($this->multilang() === true && $language = $this->languages()->find($locale)) {
+ $data = array_merge($data, $language->translations());
+
+ // Add language slug rules to Str class
+ Str::$language = $language->rules();
+ }
+
+
+ return $data;
+ };
+
+ I18n::$locale = function (): string {
+ if ($this->multilang() === true) {
+ return $this->defaultLanguage()->code();
+ } else {
+ return 'en';
+ }
+ };
+
+ I18n::$fallback = function (): string {
+ if ($this->multilang() === true) {
+ return $this->defaultLanguage()->code();
+ } else {
+ return 'en';
+ }
+ };
+
+ I18n::$translations = [];
+
+ if (isset($this->options['slugs']) === true) {
+ $file = $this->root('i18n:rules') . '/' . $this->options['slugs'] . '.json';
+
+ if (F::exists($file) === true) {
+ try {
+ $data = Data::read($file);
+ } catch (\Exception $e) {
+ $data = [];
+ }
+
+ Str::$language = $data;
+ }
+ }
+ }
+
+ /**
+ * Load and set the current language if it exists
+ * Otherwise fall back to the default language
+ *
+ * @internal
+ * @param string $languageCode
+ * @return \Kirby\Cms\Language|null
+ */
+ public function setCurrentLanguage(string $languageCode = null)
+ {
+ if ($this->multilang() === false) {
+ $this->setLocale($this->option('locale', 'en_US.utf-8'));
+ return $this->language = null;
+ }
+
+ if ($language = $this->language($languageCode)) {
+ $this->language = $language;
+ } else {
+ $this->language = $this->defaultLanguage();
+ }
+
+ if ($this->language) {
+ $this->setLocale($this->language->locale());
+ }
+
+ return $this->language;
+ }
+
+ /**
+ * Set the current translation
+ *
+ * @internal
+ * @param string $translationCode
+ * @return void
+ */
+ public function setCurrentTranslation(string $translationCode = null): void
+ {
+ I18n::$locale = $translationCode ?? 'en';
+ }
+
+ /**
+ * Set locale settings
+ *
+ * @internal
+ * @param string|array $locale
+ */
+ public function setLocale($locale): void
+ {
+ if (is_array($locale) === true) {
+ foreach ($locale as $key => $value) {
+ setlocale($key, $value);
+ }
+ } else {
+ setlocale(LC_ALL, $locale);
+ }
+ }
+
+ /**
+ * Load a specific translation by locale
+ *
+ * @param string|null $locale
+ * @return \Kirby\Cms\Translation|null
+ */
+ public function translation(string $locale = null)
+ {
+ $locale = $locale ?? I18n::locale();
+ $locale = basename($locale);
+
+ // prefer loading them from the translations collection
+ if (is_a($this->translations, 'Kirby\Cms\Translations') === true) {
+ if ($translation = $this->translations()->find($locale)) {
+ return $translation;
+ }
+ }
+
+ // get injected translation data from plugins etc.
+ $inject = $this->extensions['translations'][$locale] ?? [];
+
+ // load from disk instead
+ return Translation::load($locale, $this->root('i18n:translations') . '/' . $locale . '.json', $inject);
+ }
+
+ /**
+ * Returns all available translations
+ *
+ * @return \Kirby\Cms\Translations
+ */
+ public function translations()
+ {
+ if (is_a($this->translations, 'Kirby\Cms\Translations') === true) {
+ return $this->translations;
+ }
+
+ return Translations::load($this->root('i18n:translations'), $this->extensions['translations'] ?? []);
+ }
+}
diff --git a/kirby/src/Cms/AppUsers.php b/kirby/src/Cms/AppUsers.php
new file mode 100755
index 0000000..98697f2
--- /dev/null
+++ b/kirby/src/Cms/AppUsers.php
@@ -0,0 +1,113 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://getkirby.com/license
+ */
+trait AppUsers
+{
+ /**
+ * Cache for the auth auth layer
+ *
+ * @var Auth
+ */
+ protected $auth;
+
+ /**
+ * Returns the Authentication layer class
+ *
+ * @internal
+ * @return \Kirby\Cms\Auth
+ */
+ public function auth()
+ {
+ return $this->auth = $this->auth ?? new Auth($this);
+ }
+
+ /**
+ * Become any existing user
+ *
+ * @param string|null $who
+ * @return \Kirby\Cms\User|null
+ */
+ public function impersonate(string $who = null)
+ {
+ return $this->auth()->impersonate($who);
+ }
+
+ /**
+ * Set the currently active user id
+ *
+ * @param \Kirby\Cms\User|string $user
+ * @return \Kirby\Cms\App
+ */
+ protected function setUser($user = null)
+ {
+ $this->user = $user;
+ return $this;
+ }
+
+ /**
+ * Create your own set of app users
+ *
+ * @param array $users
+ * @return \Kirby\Cms\App
+ */
+ protected function setUsers(array $users = null)
+ {
+ if ($users !== null) {
+ $this->users = Users::factory($users, [
+ 'kirby' => $this
+ ]);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Returns a specific user by id
+ * or the current user if no id is given
+ *
+ * @param string $id
+ * @return \Kirby\Cms\User|null
+ */
+ public function user(string $id = null)
+ {
+ if ($id !== null) {
+ return $this->users()->find($id);
+ }
+
+ if (is_string($this->user) === true) {
+ return $this->auth()->impersonate($this->user);
+ } else {
+ try {
+ return $this->auth()->user();
+ } catch (Throwable $e) {
+ return null;
+ }
+ }
+ }
+
+ /**
+ * Returns all users
+ *
+ * @return \Kirby\Cms\Users
+ */
+ public function users()
+ {
+ if (is_a($this->users, 'Kirby\Cms\Users') === true) {
+ return $this->users;
+ }
+
+ return $this->users = Users::load($this->root('accounts'), ['kirby' => $this]);
+ }
+}
diff --git a/kirby/src/Cms/Asset.php b/kirby/src/Cms/Asset.php
new file mode 100755
index 0000000..a722238
--- /dev/null
+++ b/kirby/src/Cms/Asset.php
@@ -0,0 +1,126 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://getkirby.com/license
+ */
+class Asset
+{
+ use FileFoundation;
+ use FileModifications;
+ use Properties;
+
+ /**
+ * @var string
+ */
+ protected $path;
+
+ /**
+ * Creates a new Asset object
+ * for the given path.
+ *
+ * @param string $path
+ */
+ public function __construct(string $path)
+ {
+ $this->setPath(dirname($path));
+ $this->setRoot($this->kirby()->root('index') . '/' . $path);
+ $this->setUrl($this->kirby()->url('index') . '/' . $path);
+ }
+
+ /**
+ * Returns the alternative text for the asset
+ *
+ * @return null
+ */
+ public function alt()
+ {
+ return null;
+ }
+
+ /**
+ * Returns a unique id for the asset
+ *
+ * @return string
+ */
+ public function id(): string
+ {
+ return $this->root();
+ }
+
+ /**
+ * Create a unique media hash
+ *
+ * @return string
+ */
+ public function mediaHash(): string
+ {
+ return crc32($this->filename()) . '-' . $this->modified();
+ }
+
+ /**
+ * Returns the relative path starting at the media folder
+ *
+ * @return string
+ */
+ public function mediaPath(): string
+ {
+ return 'assets/' . $this->path() . '/' . $this->mediaHash() . '/' . $this->filename();
+ }
+
+ /**
+ * Returns the absolute path to the file in the public media folder
+ *
+ * @return string
+ */
+ public function mediaRoot(): string
+ {
+ return $this->kirby()->root('media') . '/' . $this->mediaPath();
+ }
+
+ /**
+ * Returns the absolute Url to the file in the public media folder
+ *
+ * @return string
+ */
+ public function mediaUrl(): string
+ {
+ return $this->kirby()->url('media') . '/' . $this->mediaPath();
+ }
+
+ /**
+ * Returns the path of the file from the web root,
+ * excluding the filename
+ *
+ * @return string
+ */
+ public function path(): string
+ {
+ return $this->path;
+ }
+
+ /**
+ * Setter for the path
+ *
+ * @param string $path
+ * @return self
+ */
+ protected function setPath(string $path)
+ {
+ $this->path = $path === '.' ? '' : $path;
+ return $this;
+ }
+}
diff --git a/kirby/src/Cms/Auth.php b/kirby/src/Cms/Auth.php
new file mode 100755
index 0000000..bd2b6dd
--- /dev/null
+++ b/kirby/src/Cms/Auth.php
@@ -0,0 +1,476 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://getkirby.com/license
+ */
+class Auth
+{
+ protected $impersonate;
+ protected $kirby;
+ protected $user = false;
+ protected $userException;
+
+ /**
+ * @param \Kirby\Cms\App $kirby
+ * @codeCoverageIgnore
+ */
+ public function __construct(App $kirby)
+ {
+ $this->kirby = $kirby;
+ }
+
+ /**
+ * Returns the csrf token if it exists and if it is valid
+ *
+ * @return string|false
+ */
+ public function csrf()
+ {
+ // get the csrf from the header
+ $fromHeader = $this->kirby->request()->csrf();
+
+ // check for a predefined csrf or use the one from session
+ $fromSession = $this->kirby->option('api.csrf', csrf());
+
+ // compare both tokens
+ if (hash_equals((string)$fromSession, (string)$fromHeader) !== true) {
+ return false;
+ }
+
+ return $fromSession;
+ }
+
+ /**
+ * Returns the logged in user by checking
+ * for a basic authentication header with
+ * valid credentials
+ *
+ * @param \Kirby\Http\Request\Auth\BasicAuth|null $auth
+ * @return \Kirby\Cms\User|null
+ */
+ public function currentUserFromBasicAuth(BasicAuth $auth = null)
+ {
+ if ($this->kirby->option('api.basicAuth', false) !== true) {
+ throw new PermissionException('Basic authentication is not activated');
+ }
+
+ $request = $this->kirby->request();
+ $auth = $auth ?? $request->auth();
+
+ if (!$auth || $auth->type() !== 'basic') {
+ throw new InvalidArgumentException('Invalid authorization header');
+ }
+
+ // only allow basic auth when https is enabled or insecure requests permitted
+ if ($request->ssl() === false && $this->kirby->option('api.allowInsecure', false) !== true) {
+ throw new PermissionException('Basic authentication is only allowed over HTTPS');
+ }
+
+ return $this->validatePassword($auth->username(), $auth->password());
+ }
+
+ /**
+ * Returns the logged in user by checking
+ * the current session and finding a valid
+ * valid user id in there
+ *
+ * @param \Kirby\Session\Session|array|null $session
+ * @return \Kirby\Cms\User|null
+ */
+ public function currentUserFromSession($session = null)
+ {
+ // use passed session options or session object if set
+ if (is_array($session) === true) {
+ $session = $this->kirby->session($session);
+ }
+
+ // try session in header or cookie
+ if (is_a($session, 'Kirby\Session\Session') === false) {
+ $session = $this->kirby->session(['detect' => true]);
+ }
+
+ $id = $session->data()->get('user.id');
+
+ if (is_string($id) !== true) {
+ return null;
+ }
+
+ if ($user = $this->kirby->users()->find($id)) {
+ // in case the session needs to be updated, do it now
+ // for better performance
+ $session->commit();
+ return $user;
+ }
+
+ return null;
+ }
+
+ /**
+ * Become any existing user
+ *
+ * @param string|null $who
+ * @return \Kirby\Cms\User|null
+ */
+ public function impersonate(string $who = null)
+ {
+ switch ($who) {
+ case null:
+ return $this->impersonate = null;
+ case 'kirby':
+ return $this->impersonate = new User([
+ 'email' => 'kirby@getkirby.com',
+ 'id' => 'kirby',
+ 'role' => 'admin',
+ ]);
+ default:
+ if ($user = $this->kirby->users()->find($who)) {
+ return $this->impersonate = $user;
+ }
+
+ throw new NotFoundException('The user "' . $who . '" cannot be found');
+ }
+ }
+
+ /**
+ * Returns the hashed ip of the visitor
+ * which is used to track invalid logins
+ *
+ * @return string
+ */
+ public function ipHash(): string
+ {
+ $hash = hash('sha256', $this->kirby->visitor()->ip());
+
+ // only use the first 50 chars to ensure privacy
+ return substr($hash, 0, 50);
+ }
+
+ /**
+ * Check if logins are blocked for the current ip or email
+ *
+ * @param string $email
+ * @return bool
+ */
+ public function isBlocked(string $email): bool
+ {
+ $ip = $this->ipHash();
+ $log = $this->log();
+ $trials = $this->kirby->option('auth.trials', 10);
+
+ if ($entry = ($log['by-ip'][$ip] ?? null)) {
+ if ($entry['trials'] >= $trials) {
+ return true;
+ }
+ }
+
+ if ($this->kirby->users()->find($email)) {
+ if ($entry = ($log['by-email'][$email] ?? null)) {
+ if ($entry['trials'] >= $trials) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Login a user by email and password
+ *
+ * @param string $email
+ * @param string $password
+ * @param bool $long
+ * @return \Kirby\Cms\User
+ *
+ * @throws \Kirby\Exception\PermissionException If the rate limit was exceeded or if any other error occured with debug mode off
+ * @throws \Kirby\Exception\NotFoundException If the email was invalid
+ * @throws \Kirby\Exception\InvalidArgumentException If the password is not valid (via `$user->login()`)
+ */
+ public function login(string $email, string $password, bool $long = false)
+ {
+ // session options
+ $options = [
+ 'createMode' => 'cookie',
+ 'long' => $long === true
+ ];
+
+ // validate the user and log in to the session
+ $user = $this->validatePassword($email, $password);
+ $user->loginPasswordless($options);
+
+ return $user;
+ }
+
+ /**
+ * Sets a user object as the current user in the cache
+ * @internal
+ *
+ * @param \Kirby\Cms\User $user
+ * @return void
+ */
+ public function setUser(User $user): void
+ {
+ // stop impersonating
+ $this->impersonate = null;
+
+ $this->user = $user;
+ }
+
+ /**
+ * Validates the user credentials and returns the user object on success;
+ * otherwise logs the failed attempt
+ *
+ * @param string $email
+ * @param string $password
+ * @return \Kirby\Cms\User
+ *
+ * @throws \Kirby\Exception\PermissionException If the rate limit was exceeded or if any other error occured with debug mode off
+ * @throws \Kirby\Exception\NotFoundException If the email was invalid
+ * @throws \Kirby\Exception\InvalidArgumentException If the password is not valid (via `$user->login()`)
+ */
+ public function validatePassword(string $email, string $password)
+ {
+ // check for blocked ips
+ if ($this->isBlocked($email) === true) {
+ if ($this->kirby->option('debug') === true) {
+ $message = 'Rate limit exceeded';
+ } else {
+ // avoid leaking security-relevant information
+ $message = 'Invalid email or password';
+ }
+
+ throw new PermissionException($message);
+ }
+
+ // validate the user
+ try {
+ if ($user = $this->kirby->users()->find($email)) {
+ if ($user->validatePassword($password) === true) {
+ return $user;
+ }
+ }
+
+ throw new NotFoundException([
+ 'key' => 'user.notFound',
+ 'data' => [
+ 'name' => $email
+ ]
+ ]);
+ } catch (Throwable $e) {
+ // log invalid login trial
+ $this->track($email);
+
+ // sleep for a random amount of milliseconds
+ // to make automated attacks harder
+ usleep(random_int(1000, 2000000));
+
+ // keep throwing the original error in debug mode,
+ // otherwise hide it to avoid leaking security-relevant information
+ if ($this->kirby->option('debug') === true) {
+ throw $e;
+ } else {
+ throw new PermissionException('Invalid email or password');
+ }
+ }
+ }
+
+ /**
+ * Returns the absolute path to the logins log
+ *
+ * @return string
+ */
+ public function logfile(): string
+ {
+ return $this->kirby->root('accounts') . '/.logins';
+ }
+
+ /**
+ * Read all tracked logins
+ *
+ * @return array
+ */
+ public function log(): array
+ {
+ try {
+ $log = Data::read($this->logfile(), 'json');
+ $read = true;
+ } catch (Throwable $e) {
+ $log = [];
+ $read = false;
+ }
+
+ // ensure that the category arrays are defined
+ $log['by-ip'] = $log['by-ip'] ?? [];
+ $log['by-email'] = $log['by-email'] ?? [];
+
+ // remove all elements on the top level with different keys (old structure)
+ $log = array_intersect_key($log, array_flip(['by-ip', 'by-email']));
+
+ // remove entries that are no longer needed
+ $originalLog = $log;
+ $time = time() - $this->kirby->option('auth.timeout', 3600);
+ foreach ($log as $category => $entries) {
+ $log[$category] = array_filter($entries, function ($entry) use ($time) {
+ return $entry['time'] > $time;
+ });
+ }
+
+ // write new log to the file system if it changed
+ if ($read === false || $log !== $originalLog) {
+ if (count($log['by-ip']) === 0 && count($log['by-email']) === 0) {
+ F::remove($this->logfile());
+ } else {
+ Data::write($this->logfile(), $log, 'json');
+ }
+ }
+
+ return $log;
+ }
+
+ /**
+ * Logout the current user
+ *
+ * @return void
+ */
+ public function logout(): void
+ {
+ // stop impersonating;
+ // ensures that we log out the actually logged in user
+ $this->impersonate = null;
+
+ // logout the current user if it exists
+ if ($user = $this->user()) {
+ $user->logout();
+ }
+ }
+
+ /**
+ * Clears the cached user data after logout
+ * @internal
+ *
+ * @return void
+ */
+ public function flush(): void
+ {
+ $this->impersonate = null;
+ $this->user = null;
+ }
+
+ /**
+ * Tracks a login
+ *
+ * @param string $email
+ * @return bool
+ */
+ public function track(string $email): bool
+ {
+ $ip = $this->ipHash();
+ $log = $this->log();
+ $time = time();
+
+ if (isset($log['by-ip'][$ip]) === true) {
+ $log['by-ip'][$ip] = [
+ 'time' => $time,
+ 'trials' => ($log['by-ip'][$ip]['trials'] ?? 0) + 1
+ ];
+ } else {
+ $log['by-ip'][$ip] = [
+ 'time' => $time,
+ 'trials' => 1
+ ];
+ }
+
+ if ($this->kirby->users()->find($email)) {
+ if (isset($log['by-email'][$email]) === true) {
+ $log['by-email'][$email] = [
+ 'time' => $time,
+ 'trials' => ($log['by-email'][$email]['trials'] ?? 0) + 1
+ ];
+ } else {
+ $log['by-email'][$email] = [
+ 'time' => $time,
+ 'trials' => 1
+ ];
+ }
+ }
+
+ return Data::write($this->logfile(), $log, 'json');
+ }
+
+ /**
+ * Returns the current authentication type
+ *
+ * @return string
+ */
+ public function type(): string
+ {
+ $basicAuth = $this->kirby->option('api.basicAuth', false);
+ $auth = $this->kirby->request()->auth();
+
+ if ($basicAuth === true && $auth && $auth->type() === 'basic') {
+ return 'basic';
+ } elseif ($this->impersonate !== null) {
+ return 'impersonate';
+ } else {
+ return 'session';
+ }
+ }
+
+ /**
+ * Validates the currently logged in user
+ *
+ * @param \Kirby\Session\Session|array|null $session
+ * @return \Kirby\Cms\User
+ *
+ * @throws \Throwable If an authentication error occured
+ */
+ public function user($session = null)
+ {
+ if ($this->impersonate !== null) {
+ return $this->impersonate;
+ }
+
+ // return from cache
+ if ($this->user === null) {
+ // throw the same Exception again if one was captured before
+ if ($this->userException !== null) {
+ throw $this->userException;
+ }
+
+ return null;
+ } elseif ($this->user !== false) {
+ return $this->user;
+ }
+
+ try {
+ if ($this->type() === 'basic') {
+ return $this->user = $this->currentUserFromBasicAuth();
+ } else {
+ return $this->user = $this->currentUserFromSession($session);
+ }
+ } catch (Throwable $e) {
+ $this->user = null;
+
+ // capture the Exception for future calls
+ $this->userException = $e;
+
+ throw $e;
+ }
+ }
+}
diff --git a/kirby/src/Cms/Blueprint.php b/kirby/src/Cms/Blueprint.php
new file mode 100755
index 0000000..fb7003d
--- /dev/null
+++ b/kirby/src/Cms/Blueprint.php
@@ -0,0 +1,797 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://getkirby.com/license
+ */
+class Blueprint
+{
+ public static $presets = [];
+ public static $loaded = [];
+
+ protected $fields = [];
+ protected $model;
+ protected $props;
+ protected $sections = [];
+ protected $tabs = [];
+
+ /**
+ * Magic getter/caller for any blueprint prop
+ *
+ * @param string $key
+ * @param array $arguments
+ * @return mixed
+ */
+ public function __call(string $key, array $arguments = null)
+ {
+ return $this->props[$key] ?? null;
+ }
+
+ /**
+ * Creates a new blueprint object with the given props
+ *
+ * @param array $props
+ */
+ public function __construct(array $props)
+ {
+ if (empty($props['model']) === true) {
+ throw new InvalidArgumentException('A blueprint model is required');
+ }
+
+ $this->model = $props['model'];
+
+ // the model should not be included in the props array
+ unset($props['model']);
+
+ // extend the blueprint in general
+ $props = $this->extend($props);
+
+ // apply any blueprint preset
+ $props = $this->preset($props);
+
+ // normalize the name
+ $props['name'] = $props['name'] ?? 'default';
+
+ // normalize and translate the title
+ $props['title'] = $this->i18n($props['title'] ?? ucfirst($props['name']));
+
+ // convert all shortcuts
+ $props = $this->convertFieldsToSections('main', $props);
+ $props = $this->convertSectionsToColumns('main', $props);
+ $props = $this->convertColumnsToTabs('main', $props);
+
+ // normalize all tabs
+ $props['tabs'] = $this->normalizeTabs($props['tabs'] ?? []);
+
+ $this->props = $props;
+ }
+
+ /**
+ * Improved `var_dump` output
+ *
+ * @return array
+ */
+ public function __debugInfo(): array
+ {
+ return $this->props ?? [];
+ }
+
+ /**
+ * Converts all column definitions, that
+ * are not wrapped in a tab, into a generic tab
+ *
+ * @param string $tabName
+ * @param array $props
+ * @return array
+ */
+ protected function convertColumnsToTabs(string $tabName, array $props): array
+ {
+ if (isset($props['columns']) === false) {
+ return $props;
+ }
+
+ // wrap everything in a main tab
+ $props['tabs'] = [
+ $tabName => [
+ 'columns' => $props['columns']
+ ]
+ ];
+
+ unset($props['columns']);
+
+ return $props;
+ }
+
+ /**
+ * Converts all field definitions, that are not
+ * wrapped in a fields section into a generic
+ * fields section.
+ *
+ * @param string $tabName
+ * @param array $props
+ * @return array
+ */
+ protected function convertFieldsToSections(string $tabName, array $props): array
+ {
+ if (isset($props['fields']) === false) {
+ return $props;
+ }
+
+ // wrap all fields in a section
+ $props['sections'] = [
+ $tabName . '-fields' => [
+ 'type' => 'fields',
+ 'fields' => $props['fields']
+ ]
+ ];
+
+ unset($props['fields']);
+
+ return $props;
+ }
+
+ /**
+ * Converts all sections that are not wrapped in
+ * columns, into a single generic column.
+ *
+ * @param string $tabName
+ * @param array $props
+ * @return array
+ */
+ protected function convertSectionsToColumns(string $tabName, array $props): array
+ {
+ if (isset($props['sections']) === false) {
+ return $props;
+ }
+
+ // wrap everything in one big column
+ $props['columns'] = [
+ [
+ 'width' => '1/1',
+ 'sections' => $props['sections']
+ ]
+ ];
+
+ unset($props['sections']);
+
+ return $props;
+ }
+
+ /**
+ * Extends the props with props from a given
+ * mixin, when an extends key is set or the
+ * props is just a string
+ *
+ * @param array|string $props
+ * @return array
+ */
+ public static function extend($props): array
+ {
+ if (is_string($props) === true) {
+ $props = [
+ 'extends' => $props
+ ];
+ }
+
+ $extends = $props['extends'] ?? null;
+
+ if ($extends === null) {
+ return $props;
+ }
+
+ $mixin = static::find($extends);
+
+ if ($mixin === null) {
+ $props = $props;
+ } elseif (is_array($mixin) === true) {
+ $props = A::merge($mixin, $props, A::MERGE_REPLACE);
+ } else {
+ try {
+ $props = A::merge(Data::read($mixin), $props, A::MERGE_REPLACE);
+ } catch (Exception $e) {
+ $props = $props;
+ }
+ }
+
+ // remove the extends flag
+ unset($props['extends']);
+ return $props;
+ }
+
+ /**
+ * Create a new blueprint for a model
+ *
+ * @param string $name
+ * @param string $fallback
+ * @param \Kirby\Cms\Model $model
+ * @return self
+ */
+ public static function factory(string $name, string $fallback = null, Model $model)
+ {
+ try {
+ $props = static::load($name);
+ } catch (Exception $e) {
+ $props = $fallback !== null ? static::load($fallback) : null;
+ }
+
+ if ($props === null) {
+ return null;
+ }
+
+ // inject the parent model
+ $props['model'] = $model;
+
+ return new static($props);
+ }
+
+ /**
+ * Returns a single field definition by name
+ *
+ * @param string $name
+ * @return array|null
+ */
+ public function field(string $name): ?array
+ {
+ return $this->fields[$name] ?? null;
+ }
+
+ /**
+ * Returns all field definitions
+ *
+ * @return array
+ */
+ public function fields(): array
+ {
+ return $this->fields;
+ }
+
+ /**
+ * Find a blueprint by name
+ *
+ * @param string $name
+ * @return string|array
+ */
+ public static function find(string $name)
+ {
+ $kirby = App::instance();
+ $root = $kirby->root('blueprints');
+ $file = $root . '/' . $name . '.yml';
+
+ if (F::exists($file, $root) === true) {
+ return $file;
+ }
+
+ if ($blueprint = $kirby->extension('blueprints', $name)) {
+ return $blueprint;
+ }
+
+ throw new NotFoundException([
+ 'key' => 'blueprint.notFound',
+ 'data' => ['name' => $name]
+ ]);
+ }
+
+ /**
+ * Used to translate any label, heading, etc.
+ *
+ * @param mixed $value
+ * @param mixed $fallback
+ * @return mixed
+ */
+ protected function i18n($value, $fallback = null)
+ {
+ return I18n::translate($value, $fallback ?? $value);
+ }
+
+ /**
+ * Checks if this is the default blueprint
+ *
+ * @return bool
+ */
+ public function isDefault(): bool
+ {
+ return $this->name() === 'default';
+ }
+
+ /**
+ * Loads a blueprint from file or array
+ *
+ * @param string $name
+ * @return array
+ */
+ public static function load(string $name): array
+ {
+ if (isset(static::$loaded[$name]) === true) {
+ return static::$loaded[$name];
+ }
+
+ $props = static::find($name);
+ $normalize = function ($props) use ($name) {
+
+ // inject the filename as name if no name is set
+ $props['name'] = $props['name'] ?? $name;
+
+ // normalize the title
+ $title = $props['title'] ?? ucfirst($props['name']);
+
+ // translate the title
+ $props['title'] = I18n::translate($title, $title);
+
+ return $props;
+ };
+
+ if (is_array($props) === true) {
+ return $normalize($props);
+ }
+
+ $file = $props;
+ $props = Data::read($file);
+
+ return static::$loaded[$name] = $normalize($props);
+ }
+
+ /**
+ * Returns the parent model
+ *
+ * @return \Kirby\Cms\Model
+ */
+ public function model()
+ {
+ return $this->model;
+ }
+
+ /**
+ * Returns the blueprint name
+ *
+ * @return string
+ */
+ public function name(): string
+ {
+ return $this->props['name'];
+ }
+
+ /**
+ * Normalizes all required props in a column setup
+ *
+ * @param string $tabName
+ * @param array $columns
+ * @return array
+ */
+ protected function normalizeColumns(string $tabName, array $columns): array
+ {
+ foreach ($columns as $columnKey => $columnProps) {
+ if (is_array($columnProps) === false) {
+ continue;
+ }
+
+ $columnProps = $this->convertFieldsToSections($tabName . '-col-' . $columnKey, $columnProps);
+
+ // inject getting started info, if the sections are empty
+ if (empty($columnProps['sections']) === true) {
+ $columnProps['sections'] = [
+ $tabName . '-info-' . $columnKey => [
+ 'headline' => 'Column (' . ($columnProps['width'] ?? '1/1') . ')',
+ 'type' => 'info',
+ 'text' => 'No sections yet'
+ ]
+ ];
+ }
+
+ $columns[$columnKey] = array_merge($columnProps, [
+ 'width' => $columnProps['width'] ?? '1/1',
+ 'sections' => $this->normalizeSections($tabName, $columnProps['sections'] ?? [])
+ ]);
+ }
+
+ return $columns;
+ }
+
+ public static function helpList(array $items): string
+ {
+ $md = [];
+
+ foreach ($items as $item) {
+ $md[] = '- *' . $item . '*';
+ }
+
+ return PHP_EOL . implode(PHP_EOL, $md);
+ }
+
+ /**
+ * Normalize field props for a single field
+ *
+ * @param array|string $props
+ * @return array
+ */
+ public static function fieldProps($props): array
+ {
+ $props = static::extend($props);
+
+ if (isset($props['name']) === false) {
+ throw new InvalidArgumentException('The field name is missing');
+ }
+
+ $name = $props['name'];
+ $type = $props['type'] ?? $name;
+
+ if ($type !== 'group' && isset(Field::$types[$type]) === false) {
+ throw new InvalidArgumentException('Invalid field type ("' . $type . '")');
+ }
+
+ // support for nested fields
+ if (isset($props['fields']) === true) {
+ $props['fields'] = static::fieldsProps($props['fields']);
+ }
+
+ // groups don't need all the crap
+ if ($type === 'group') {
+ return [
+ 'fields' => $props['fields'],
+ 'name' => $name,
+ 'type' => $type,
+ ];
+ }
+
+ // add some useful defaults
+ return array_merge($props, [
+ 'label' => $props['label'] ?? ucfirst($name),
+ 'name' => $name,
+ 'type' => $type,
+ 'width' => $props['width'] ?? '1/1',
+ ]);
+ }
+
+ /**
+ * Creates an error field with the given error message
+ *
+ * @param string $name
+ * @param string $message
+ * @return array
+ */
+ public static function fieldError(string $name, string $message): array
+ {
+ return [
+ 'label' => 'Error',
+ 'name' => $name,
+ 'text' => strip_tags($message),
+ 'theme' => 'negative',
+ 'type' => 'info',
+ ];
+ }
+
+ /**
+ * Normalizes all fields and adds automatic labels,
+ * types and widths.
+ *
+ * @param array $fields
+ * @return array
+ */
+ public static function fieldsProps($fields): array
+ {
+ if (is_array($fields) === false) {
+ $fields = [];
+ }
+
+ foreach ($fields as $fieldName => $fieldProps) {
+
+ // extend field from string
+ if (is_string($fieldProps) === true) {
+ $fieldProps = [
+ 'extends' => $fieldProps,
+ 'name' => $fieldName
+ ];
+ }
+
+ // use the name as type definition
+ if ($fieldProps === true) {
+ $fieldProps = [];
+ }
+
+ // unset / remove field if its propperty is false
+ if ($fieldProps === false) {
+ unset($fields[$fieldName]);
+ continue;
+ }
+
+ // inject the name
+ $fieldProps['name'] = $fieldName;
+
+ // create all props
+ try {
+ $fieldProps = static::fieldProps($fieldProps);
+ } catch (Throwable $e) {
+ $fieldProps = static::fieldError($fieldName, $e->getMessage());
+ }
+
+ // resolve field groups
+ if ($fieldProps['type'] === 'group') {
+ if (empty($fieldProps['fields']) === false && is_array($fieldProps['fields']) === true) {
+ $index = array_search($fieldName, array_keys($fields));
+ $before = array_slice($fields, 0, $index);
+ $after = array_slice($fields, $index + 1);
+ $fields = array_merge($before, $fieldProps['fields'] ?? [], $after);
+ } else {
+ unset($fields[$fieldName]);
+ }
+ } else {
+ $fields[$fieldName] = $fieldProps;
+ }
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Normalizes blueprint options. This must be used in the
+ * constructor of an extended class, if you want to make use of it.
+ *
+ * @param array|true|false|null|string $options
+ * @param array $defaults
+ * @param array $aliases
+ * @return array
+ */
+ protected function normalizeOptions($options, array $defaults, array $aliases = []): array
+ {
+ // return defaults when options are not defined or set to true
+ if ($options === true) {
+ return $defaults;
+ }
+
+ // set all options to false
+ if ($options === false) {
+ return array_map(function () {
+ return false;
+ }, $defaults);
+ }
+
+ // extend options if possible
+ $options = $this->extend($options);
+
+ foreach ($options as $key => $value) {
+ $alias = $aliases[$key] ?? null;
+
+ if ($alias !== null) {
+ $options[$alias] = $options[$alias] ?? $value;
+ unset($options[$key]);
+ }
+ }
+
+ return array_merge($defaults, $options);
+ }
+
+ /**
+ * Normalizes all required keys in sections
+ *
+ * @param string $tabName
+ * @param array $sections
+ * @return array
+ */
+ protected function normalizeSections(string $tabName, array $sections): array
+ {
+ foreach ($sections as $sectionName => $sectionProps) {
+
+ // unset / remove section if its propperty is false
+ if ($sectionProps === false) {
+ unset($sections[$sectionName]);
+ continue;
+ }
+
+ // fallback to default props when true is passed
+ if ($sectionProps === true) {
+ $sectionProps = [];
+ }
+
+ // inject all section extensions
+ $sectionProps = $this->extend($sectionProps);
+
+ $sections[$sectionName] = $sectionProps = array_merge($sectionProps, [
+ 'name' => $sectionName,
+ 'type' => $type = $sectionProps['type'] ?? $sectionName
+ ]);
+
+ if (empty($type) === true || is_string($type) === false) {
+ $sections[$sectionName] = [
+ 'name' => $sectionName,
+ 'headline' => 'Invalid section type for section "' . $sectionName . '"',
+ 'type' => 'info',
+ 'text' => 'The following section types are available: ' . $this->helpList(array_keys(Section::$types))
+ ];
+ } elseif (isset(Section::$types[$type]) === false) {
+ $sections[$sectionName] = [
+ 'name' => $sectionName,
+ 'headline' => 'Invalid section type ("' . $type . '")',
+ 'type' => 'info',
+ 'text' => 'The following section types are available: ' . $this->helpList(array_keys(Section::$types))
+ ];
+ }
+
+ if ($sectionProps['type'] === 'fields') {
+ $fields = Blueprint::fieldsProps($sectionProps['fields'] ?? []);
+
+ // inject guide fields guide
+ if (empty($fields) === true) {
+ $fields = [
+ $tabName . '-info' => [
+ 'label' => 'Fields',
+ 'text' => 'No fields yet',
+ 'type' => 'info'
+ ]
+ ];
+ } else {
+ foreach ($fields as $fieldName => $fieldProps) {
+ if (isset($this->fields[$fieldName]) === true) {
+ $this->fields[$fieldName] = $fields[$fieldName] = [
+ 'type' => 'info',
+ 'label' => $fieldProps['label'] ?? 'Error',
+ 'text' => 'The field name "' . $fieldName . '" already exists in your blueprint.',
+ 'theme' => 'negative'
+ ];
+ } else {
+ $this->fields[$fieldName] = $fieldProps;
+ }
+ }
+ }
+
+ $sections[$sectionName]['fields'] = $fields;
+ }
+ }
+
+ // store all normalized sections
+ $this->sections = array_merge($this->sections, $sections);
+
+ return $sections;
+ }
+
+ /**
+ * Normalizes all required keys in tabs
+ *
+ * @param array $tabs
+ * @return array
+ */
+ protected function normalizeTabs($tabs): array
+ {
+ if (is_array($tabs) === false) {
+ $tabs = [];
+ }
+
+ foreach ($tabs as $tabName => $tabProps) {
+
+ // unset / remove tab if its propperty is false
+ if ($tabProps === false) {
+ unset($tabs[$tabName]);
+ continue;
+ }
+
+ // inject all tab extensions
+ $tabProps = $this->extend($tabProps);
+
+ // inject a preset if available
+ $tabProps = $this->preset($tabProps);
+
+ $tabProps = $this->convertFieldsToSections($tabName, $tabProps);
+ $tabProps = $this->convertSectionsToColumns($tabName, $tabProps);
+
+ $tabs[$tabName] = array_merge($tabProps, [
+ 'columns' => $this->normalizeColumns($tabName, $tabProps['columns'] ?? []),
+ 'icon' => $tabProps['icon'] ?? null,
+ 'label' => $this->i18n($tabProps['label'] ?? ucfirst($tabName)),
+ 'name' => $tabName,
+ ]);
+ }
+
+ return $this->tabs = $tabs;
+ }
+
+ /**
+ * Injects a blueprint preset
+ *
+ * @param array $props
+ * @return array
+ */
+ protected function preset(array $props): array
+ {
+ if (isset($props['preset']) === false) {
+ return $props;
+ }
+
+ if (isset(static::$presets[$props['preset']]) === false) {
+ return $props;
+ }
+
+ return static::$presets[$props['preset']]($props);
+ }
+
+ /**
+ * Returns a single section by name
+ *
+ * @param string $name
+ * @return \Kirby\Cms\Section|null
+ */
+ public function section(string $name)
+ {
+ if (empty($this->sections[$name]) === true) {
+ return null;
+ }
+
+ // get all props
+ $props = $this->sections[$name];
+
+ // inject the blueprint model
+ $props['model'] = $this->model();
+
+ // create a new section object
+ return new Section($props['type'], $props);
+ }
+
+ /**
+ * Returns all sections
+ *
+ * @return array
+ */
+ public function sections(): array
+ {
+ return array_map(function ($section) {
+ return $this->section($section['name']);
+ }, $this->sections);
+ }
+
+ /**
+ * Returns a single tab by name
+ *
+ * @param string $name
+ * @return array|null
+ */
+ public function tab(string $name): ?array
+ {
+ return $this->tabs[$name] ?? null;
+ }
+
+ /**
+ * Returns all tabs
+ *
+ * @return array
+ */
+ public function tabs(): array
+ {
+ return array_values($this->tabs);
+ }
+
+ /**
+ * Returns the blueprint title
+ *
+ * @return string
+ */
+ public function title(): string
+ {
+ return $this->props['title'];
+ }
+
+ /**
+ * Converts the blueprint object to a plain array
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->props;
+ }
+}
diff --git a/kirby/src/Cms/Collection.php b/kirby/src/Cms/Collection.php
new file mode 100755
index 0000000..7f65b95
--- /dev/null
+++ b/kirby/src/Cms/Collection.php
@@ -0,0 +1,333 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://getkirby.com/license
+ */
+class Collection extends BaseCollection
+{
+ use HasMethods;
+
+ /**
+ * Stores the parent object, which is needed
+ * in some collections to get the finder methods right.
+ *
+ * @var object
+ */
+ protected $parent;
+
+ /**
+ * Magic getter function
+ *
+ * @param string $key
+ * @param mixed $arguments
+ * @return mixed
+ */
+ public function __call(string $key, $arguments)
+ {
+ // collection methods
+ if ($this->hasMethod($key)) {
+ return $this->callMethod($key, $arguments);
+ }
+ }
+
+ /**
+ * Creates a new Collection with the given objects
+ *
+ * @param array $objects
+ * @param object $parent
+ */
+ public function __construct($objects = [], $parent = null)
+ {
+ $this->parent = $parent;
+
+ foreach ($objects as $object) {
+ $this->add($object);
+ }
+ }
+
+ /**
+ * Internal setter for each object in the Collection.
+ * This takes care of Component validation and of setting
+ * the collection prop on each object correctly.
+ *
+ * @param string $id
+ * @param object $object
+ */
+ public function __set(string $id, $object)
+ {
+ $this->data[$id] = $object;
+ }
+
+ /**
+ * Adds a single object or
+ * an entire second collection to the
+ * current collection
+ *
+ * @param mixed $object
+ */
+ public function add($object)
+ {
+ if (is_a($object, static::class) === true) {
+ $this->data = array_merge($this->data, $object->data);
+ } elseif (method_exists($object, 'id') === true) {
+ $this->__set($object->id(), $object);
+ } else {
+ $this->append($object);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Appends an element to the data array
+ *
+ * @param mixed $key Optional collection key, will be determined from the item if not given
+ * @param mixed $item
+ * @return \Kirby\Cms\Collection
+ */
+ public function append(...$args)
+ {
+ if (count($args) === 1) {
+ // try to determine the key from the provided item
+ if (is_object($args[0]) === true && is_callable([$args[0], 'id']) === true) {
+ return parent::append($args[0]->id(), $args[0]);
+ } else {
+ return parent::append($args[0]);
+ }
+ }
+
+ return parent::append(...$args);
+ }
+
+ /**
+ * Groups the items by a given field. Returns a collection
+ * with an item for each group and a collection for each group.
+ *
+ * @param string $field
+ * @param bool $i Ignore upper/lowercase for group names
+ * @return \Kirby\Cms\Collection
+ */
+ public function groupBy($field, bool $i = true)
+ {
+ if (is_string($field) === false) {
+ throw new Exception('Cannot group by non-string values. Did you mean to call group()?');
+ }
+
+ $groups = new Collection([], $this->parent());
+
+ foreach ($this->data as $key => $item) {
+ $value = $this->getAttribute($item, $field);
+
+ // make sure that there's always a proper value to group by
+ if (!$value) {
+ throw new InvalidArgumentException('Invalid grouping value for key: ' . $key);
+ }
+
+ // ignore upper/lowercase for group names
+ if ($i) {
+ $value = Str::lower($value);
+ }
+
+ if (isset($groups->data[$value]) === false) {
+ // create a new entry for the group if it does not exist yet
+ $groups->data[$value] = new static([$key => $item]);
+ } else {
+ // add the item to an existing group
+ $groups->data[$value]->set($key, $item);
+ }
+ }
+
+ return $groups;
+ }
+
+ /**
+ * Checks if the given object or id
+ * is in the collection
+ *
+ * @param string|object $id
+ * @return bool
+ */
+ public function has($id): bool
+ {
+ if (is_object($id) === true) {
+ $id = $id->id();
+ }
+
+ return parent::has($id);
+ }
+
+ /**
+ * Correct position detection for objects.
+ * The method will automatically detect objects
+ * or ids and then search accordingly.
+ *
+ * @param string|object $object
+ * @return int
+ */
+ public function indexOf($object): int
+ {
+ if (is_string($object) === true) {
+ return array_search($object, $this->keys());
+ }
+
+ return array_search($object->id(), $this->keys());
+ }
+
+ /**
+ * Returns a Collection without the given element(s)
+ *
+ * @param mixed ...$keys any number of keys, passed as individual arguments
+ * @return \Kirby\Cms\Collection
+ */
+ public function not(...$keys)
+ {
+ $collection = $this->clone();
+ foreach ($keys as $key) {
+ if (is_a($key, 'Kirby\Toolkit\Collection') === true) {
+ $collection = $collection->not(...$key->keys());
+ } elseif (is_object($key) === true) {
+ $key = $key->id();
+ }
+ unset($collection->$key);
+ }
+ return $collection;
+ }
+
+ /**
+ * Add pagination and return a sliced set of data.
+ *
+ * @param mixed ...$arguments
+ * @return \Kirby\Cms\Collection
+ */
+ public function paginate(...$arguments)
+ {
+ $this->pagination = Pagination::for($this, ...$arguments);
+
+ // slice and clone the collection according to the pagination
+ return $this->slice($this->pagination->offset(), $this->pagination->limit());
+ }
+
+ /**
+ * Returns the parent model
+ *
+ * @return \Kirby\Cms\Model
+ */
+ public function parent()
+ {
+ return $this->parent;
+ }
+
+ /**
+ * Prepends an element to the data array
+ *
+ * @param mixed $key Optional collection key, will be determined from the item if not given
+ * @param mixed $item
+ * @return \Kirby\Cms\Collection
+ */
+ public function prepend(...$args)
+ {
+ if (count($args) === 1) {
+ // try to determine the key from the provided item
+ if (is_object($args[0]) === true && is_callable([$args[0], 'id']) === true) {
+ return parent::prepend($args[0]->id(), $args[0]);
+ } else {
+ return parent::prepend($args[0]);
+ }
+ }
+
+ return parent::prepend(...$args);
+ }
+
+ /**
+ * Runs a combination of filterBy, sortBy, not
+ * offset, limit, search and paginate on the collection.
+ * Any part of the query is optional.
+ *
+ * @param array $query
+ * @return self
+ */
+ public function query(array $query = [])
+ {
+ $paginate = $query['paginate'] ?? null;
+ $search = $query['search'] ?? null;
+
+ unset($query['paginate']);
+
+ $result = parent::query($query);
+
+ if (empty($search) === false) {
+ if (is_array($search) === true) {
+ $result = $result->search($search['query'] ?? null, $search['options'] ?? []);
+ } else {
+ $result = $result->search($search);
+ }
+ }
+
+ if (empty($paginate) === false) {
+ $result = $result->paginate($paginate);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Removes an object
+ *
+ * @param mixed $key the name of the key
+ */
+ public function remove($key)
+ {
+ if (is_object($key) === true) {
+ $key = $key->id();
+ }
+
+ return parent::remove($key);
+ }
+
+ /**
+ * Searches the collection
+ *
+ * @param string $query
+ * @param array $params
+ * @return self
+ */
+ public function search(string $query = null, $params = [])
+ {
+ return Search::collection($this, $query, $params);
+ }
+
+ /**
+ * Converts all objects in the collection
+ * to an array. This can also take a callback
+ * function to further modify the array result.
+ *
+ * @param Closure $map
+ * @return array
+ */
+ public function toArray(Closure $map = null): array
+ {
+ return parent::toArray($map ?? function ($object) {
+ return $object->toArray();
+ });
+ }
+}
diff --git a/kirby/src/Cms/Collections.php b/kirby/src/Cms/Collections.php
new file mode 100755
index 0000000..8a42ce2
--- /dev/null
+++ b/kirby/src/Cms/Collections.php
@@ -0,0 +1,139 @@
+collection()`
+ * method to provide easy access to registered collections
+ *
+ * @package Kirby Cms
+ * @author Bastian Allgeier
+ *
+ * cookie::set('mycookie', 'hello', ['lifetime' => 60]);
+ * // expires in 1 hour
+ *
+ *
+ *
+ * @param string $key The name of the cookie
+ * @param string $value The cookie content
+ * @param array $options Array of options:
+ * lifetime, path, domain, secure, httpOnly
+ * @return bool true: cookie was created,
+ * false: cookie creation failed
+ */
+ public static function set(string $key, string $value, array $options = []): bool
+ {
+ // extract options
+ $lifetime = $options['lifetime'] ?? 0;
+ $path = $options['path'] ?? '/';
+ $domain = $options['domain'] ?? null;
+ $secure = $options['secure'] ?? false;
+ $httpOnly = $options['httpOnly'] ?? true;
+
+ // add an HMAC signature of the value
+ $value = static::hmac($value) . '+' . $value;
+
+ // store that thing in the cookie global
+ $_COOKIE[$key] = $value;
+
+ // store the cookie
+ return setcookie($key, $value, static::lifetime($lifetime), $path, $domain, $secure, $httpOnly);
+ }
+
+ /**
+ * Calculates the lifetime for a cookie
+ *
+ * @param int $minutes Number of minutes or timestamp
+ * @return int
+ */
+ public static function lifetime(int $minutes): int
+ {
+ if ($minutes > 1000000000) {
+ // absolute timestamp
+ return $minutes;
+ } elseif ($minutes > 0) {
+ // minutes from now
+ return time() + ($minutes * 60);
+ } else {
+ return 0;
+ }
+ }
+
+ /**
+ * Stores a cookie forever
+ *
+ *
+ *
+ * cookie::forever('mycookie', 'hello');
+ * // never expires
+ *
+ *
+ *
+ * @param string $key The name of the cookie
+ * @param string $value The cookie content
+ * @param array $options Array of options:
+ * path, domain, secure, httpOnly
+ * @return bool true: cookie was created,
+ * false: cookie creation failed
+ */
+ public static function forever(string $key, string $value, array $options = []): bool
+ {
+ $options['lifetime'] = 253402214400; // 9999-12-31
+ return static::set($key, $value, $options);
+ }
+
+ /**
+ * Get a cookie value
+ *
+ *
+ *
+ * cookie::get('mycookie', 'peter');
+ * // sample output: 'hello' or if the cookie is not set 'peter'
+ *
+ *
+ *
+ * @param string|null $key The name of the cookie
+ * @param string|null $default The default value, which should be returned
+ * if the cookie has not been found
+ * @return mixed The found value
+ */
+ public static function get(string $key = null, string $default = null)
+ {
+ if ($key === null) {
+ return $_COOKIE;
+ }
+ $value = $_COOKIE[$key] ?? null;
+ return empty($value) ? $default : static::parse($value);
+ }
+
+ /**
+ * Checks if a cookie exists
+ *
+ * @param string $key
+ * @return bool
+ */
+ public static function exists(string $key): bool
+ {
+ return static::get($key) !== null;
+ }
+
+ /**
+ * Creates a HMAC for the cookie value
+ * Used as a cookie signature to prevent easy tampering with cookie data
+ *
+ * @param string $value
+ * @return string
+ */
+ protected static function hmac(string $value): string
+ {
+ return hash_hmac('sha1', $value, static::$key);
+ }
+
+ /**
+ * Parses the hashed value from a cookie
+ * and tries to extract the value
+ *
+ * @param string $string
+ * @return mixed
+ */
+ protected static function parse(string $string)
+ {
+ // if no hash-value separator is present, we can't parse the value
+ if (strpos($string, '+') === false) {
+ return null;
+ }
+
+ // extract hash and value
+ $hash = Str::before($string, '+');
+ $value = Str::after($string, '+');
+
+ // if the hash or the value is missing at all return null
+ // $value can be an empty string, $hash can't be!
+ if (!is_string($hash) || $hash === '' || !is_string($value)) {
+ return null;
+ }
+
+ // compare the extracted hash with the hashed value
+ // don't accept value if the hash is invalid
+ if (hash_equals(static::hmac($value), $hash) !== true) {
+ return null;
+ }
+
+ return $value;
+ }
+
+ /**
+ * Remove a cookie
+ *
+ *
+ *
+ * cookie::remove('mycookie');
+ * // mycookie is now gone
+ *
+ *
+ *
+ * @param string $key The name of the cookie
+ * @return bool true: the cookie has been removed,
+ * false: the cookie could not be removed
+ */
+ public static function remove(string $key): bool
+ {
+ if (isset($_COOKIE[$key])) {
+ unset($_COOKIE[$key]);
+ return setcookie($key, '', 1, '/') && setcookie($key, false);
+ }
+
+ return false;
+ }
+}
diff --git a/kirby/src/Http/Exceptions/NextRouteException.php b/kirby/src/Http/Exceptions/NextRouteException.php
new file mode 100755
index 0000000..de85efa
--- /dev/null
+++ b/kirby/src/Http/Exceptions/NextRouteException.php
@@ -0,0 +1,16 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class NextRouteException extends \Exception
+{
+}
diff --git a/kirby/src/Http/Header.php b/kirby/src/Http/Header.php
new file mode 100755
index 0000000..46f9138
--- /dev/null
+++ b/kirby/src/Http/Header.php
@@ -0,0 +1,316 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Header
+{
+ // configuration
+ public static $codes = [
+
+ // successful
+ '_200' => 'OK',
+ '_201' => 'Created',
+ '_202' => 'Accepted',
+
+ // redirection
+ '_300' => 'Multiple Choices',
+ '_301' => 'Moved Permanently',
+ '_302' => 'Found',
+ '_303' => 'See Other',
+ '_304' => 'Not Modified',
+ '_307' => 'Temporary Redirect',
+ '_308' => 'Permanent Redirect',
+
+ // client error
+ '_400' => 'Bad Request',
+ '_401' => 'Unauthorized',
+ '_402' => 'Payment Required',
+ '_403' => 'Forbidden',
+ '_404' => 'Not Found',
+ '_405' => 'Method Not Allowed',
+ '_406' => 'Not Acceptable',
+ '_410' => 'Gone',
+ '_418' => 'I\'m a teapot',
+ '_451' => 'Unavailable For Legal Reasons',
+
+ // server error
+ '_500' => 'Internal Server Error',
+ '_501' => 'Not Implemented',
+ '_502' => 'Bad Gateway',
+ '_503' => 'Service Unavailable',
+ '_504' => 'Gateway Time-out'
+ ];
+
+ /**
+ * Sends a content type header
+ *
+ * @param string $mime
+ * @param string $charset
+ * @param bool $send
+ * @return string|void
+ */
+ public static function contentType(string $mime, string $charset = 'UTF-8', bool $send = true)
+ {
+ if ($found = F::extensionToMime($mime)) {
+ $mime = $found;
+ }
+
+ $header = 'Content-type: ' . $mime;
+
+ if (empty($charset) === false) {
+ $header .= '; charset=' . $charset;
+ }
+
+ if ($send === false) {
+ return $header;
+ }
+
+ header($header);
+ }
+
+ /**
+ * Creates headers by key and value
+ *
+ * @param string|array $key
+ * @param string|null $value
+ * @return string
+ */
+ public static function create($key, string $value = null): string
+ {
+ if (is_array($key) === true) {
+ $headers = [];
+
+ foreach ($key as $k => $v) {
+ $headers[] = static::create($k, $v);
+ }
+
+ return implode("\r\n", $headers);
+ }
+
+ // prevent header injection by stripping any newline characters from single headers
+ return str_replace(["\r", "\n"], '', $key . ': ' . $value);
+ }
+
+ /**
+ * Shortcut for static::contentType()
+ *
+ * @param string $mime
+ * @param string $charset
+ * @param bool $send
+ * @return string|void
+ */
+ public static function type(string $mime, string $charset = 'UTF-8', bool $send = true)
+ {
+ return static::contentType($mime, $charset, $send);
+ }
+
+ /**
+ * Sends a status header
+ *
+ * Checks $code against a list of known status codes. To bypass this check
+ * and send a custom status code and message, use a $code string formatted
+ * as 3 digits followed by a space and a message, e.g. '999 Custom Status'.
+ *
+ * @param int|string $code The HTTP status code
+ * @param bool $send If set to false the header will be returned instead
+ * @return string|void
+ */
+ public static function status($code = null, bool $send = true)
+ {
+ $codes = static::$codes;
+ $protocol = $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.1';
+
+ // allow full control over code and message
+ if (is_string($code) === true && preg_match('/^\d{3} \w.+$/', $code) === 1) {
+ $message = substr(rtrim($code), 4);
+ $code = substr($code, 0, 3);
+ } else {
+ $code = array_key_exists('_' . $code, $codes) === false ? 500 : $code;
+ $message = $codes['_' . $code] ?? 'Something went wrong';
+ }
+
+ $header = $protocol . ' ' . $code . ' ' . $message;
+
+ if ($send === false) {
+ return $header;
+ }
+
+ // try to send the header
+ header($header);
+ }
+
+ /**
+ * Sends a 200 header
+ *
+ * @param bool $send
+ * @return string|void
+ */
+ public static function success(bool $send = true)
+ {
+ return static::status(200, $send);
+ }
+
+ /**
+ * Sends a 201 header
+ *
+ * @param bool $send
+ * @return string|void
+ */
+ public static function created(bool $send = true)
+ {
+ return static::status(201, $send);
+ }
+
+ /**
+ * Sends a 202 header
+ *
+ * @param bool $send
+ * @return string|void
+ */
+ public static function accepted(bool $send = true)
+ {
+ return static::status(202, $send);
+ }
+
+ /**
+ * Sends a 400 header
+ *
+ * @param bool $send
+ * @return string|void
+ */
+ public static function error(bool $send = true)
+ {
+ return static::status(400, $send);
+ }
+
+ /**
+ * Sends a 403 header
+ *
+ * @param bool $send
+ * @return string|void
+ */
+ public static function forbidden(bool $send = true)
+ {
+ return static::status(403, $send);
+ }
+
+ /**
+ * Sends a 404 header
+ *
+ * @param bool $send
+ * @return string|void
+ */
+ public static function notfound(bool $send = true)
+ {
+ return static::status(404, $send);
+ }
+
+ /**
+ * Sends a 404 header
+ *
+ * @param bool $send
+ * @return string|void
+ */
+ public static function missing(bool $send = true)
+ {
+ return static::status(404, $send);
+ }
+
+ /**
+ * Sends a 410 header
+ *
+ * @param bool $send
+ * @return string|void
+ */
+ public static function gone(bool $send = true)
+ {
+ return static::status(410, $send);
+ }
+
+ /**
+ * Sends a 500 header
+ *
+ * @param bool $send
+ * @return string|void
+ */
+ public static function panic(bool $send = true)
+ {
+ return static::status(500, $send);
+ }
+
+ /**
+ * Sends a 503 header
+ *
+ * @param bool $send
+ * @return string|void
+ */
+ public static function unavailable(bool $send = true)
+ {
+ return static::status(503, $send);
+ }
+
+ /**
+ * Sends a redirect header
+ *
+ * @param string $url
+ * @param int $code
+ * @param bool $send
+ * @return string|void
+ */
+ public static function redirect(string $url, int $code = 302, bool $send = true)
+ {
+ $status = static::status($code, false);
+ $location = 'Location:' . Url::unIdn($url);
+
+ if ($send !== true) {
+ return $status . "\r\n" . $location;
+ }
+
+ header($status);
+ header($location);
+ exit();
+ }
+
+ /**
+ * Sends download headers for anything that is downloadable
+ *
+ * @param array $params Check out the defaults array for available parameters
+ */
+ public static function download(array $params = [])
+ {
+ $defaults = [
+ 'name' => 'download',
+ 'size' => false,
+ 'mime' => 'application/force-download',
+ 'modified' => time()
+ ];
+
+ $options = array_merge($defaults, $params);
+
+ header('Pragma: public');
+ header('Expires: 0');
+ header('Last-Modified: ' . gmdate('D, d M Y H:i:s', $options['modified']) . ' GMT');
+ header('Content-Disposition: attachment; filename="' . $options['name'] . '"');
+ header('Content-Transfer-Encoding: binary');
+
+ static::contentType($options['mime']);
+
+ if ($options['size']) {
+ header('Content-Length: ' . $options['size']);
+ }
+
+ header('Connection: close');
+ }
+}
diff --git a/kirby/src/Http/Idn.php b/kirby/src/Http/Idn.php
new file mode 100755
index 0000000..4c5dd2e
--- /dev/null
+++ b/kirby/src/Http/Idn.php
@@ -0,0 +1,27 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Idn
+{
+ public static function decode(string $domain)
+ {
+ return (new Punycode())->decode($domain);
+ }
+
+ public static function encode(string $domain)
+ {
+ return (new Punycode())->encode($domain);
+ }
+}
diff --git a/kirby/src/Http/Params.php b/kirby/src/Http/Params.php
new file mode 100755
index 0000000..5d0fa44
--- /dev/null
+++ b/kirby/src/Http/Params.php
@@ -0,0 +1,149 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Params extends Query
+{
+ /**
+ * @var null|string
+ */
+ public static $separator;
+
+ /**
+ * Creates a new params object
+ *
+ * @param array|string $params
+ */
+ public function __construct($params)
+ {
+ if (is_string($params) === true) {
+ $params = static::extract($params)['params'];
+ }
+
+ parent::__construct($params ?? []);
+ }
+
+ /**
+ * Extract the params from a string or array
+ *
+ * @param string|array|null $path
+ * @return array
+ */
+ public static function extract($path = null): array
+ {
+ if (empty($path) === true) {
+ return [
+ 'path' => null,
+ 'params' => null,
+ 'slash' => false
+ ];
+ }
+
+ $slash = false;
+
+ if (is_string($path) === true) {
+ $slash = substr($path, -1, 1) === '/';
+ $path = Str::split($path, '/');
+ }
+
+ if (is_array($path) === true) {
+ $params = [];
+ $separator = static::separator();
+
+ foreach ($path as $index => $p) {
+ if (strpos($p, $separator) === false) {
+ continue;
+ }
+
+ $paramParts = Str::split($p, $separator);
+ $paramKey = $paramParts[0];
+ $paramValue = $paramParts[1] ?? null;
+
+ $params[$paramKey] = $paramValue;
+ unset($path[$index]);
+ }
+
+ return [
+ 'path' => $path,
+ 'params' => $params,
+ 'slash' => $slash
+ ];
+ }
+
+ return [
+ 'path' => null,
+ 'params' => null,
+ 'slash' => false
+ ];
+ }
+
+ /**
+ * Returns the param separator according
+ * to the operating system.
+ *
+ * Unix = ':'
+ * Windows = ';'
+ *
+ * @return string
+ */
+ public static function separator(): string
+ {
+ if (static::$separator !== null) {
+ return static::$separator;
+ }
+
+ if (DIRECTORY_SEPARATOR === '/') {
+ return static::$separator = ':';
+ } else {
+ return static::$separator = ';';
+ }
+ }
+
+ /**
+ * Converts the params object to a params string
+ * which can then be used in the URL builder again
+ *
+ * @param bool $leadingSlash
+ * @param bool $trailingSlash
+ * @return string|null
+ */
+ public function toString($leadingSlash = false, $trailingSlash = false): string
+ {
+ if ($this->isEmpty() === true) {
+ return '';
+ }
+
+ $params = [];
+ $separator = static::separator();
+
+ foreach ($this as $key => $value) {
+ if ($value !== null && $value !== '') {
+ $params[] = $key . $separator . $value;
+ }
+ }
+
+ if (empty($params) === true) {
+ return '';
+ }
+
+ $params = implode('/', $params);
+
+ $leadingSlash = $leadingSlash === true ? '/' : null;
+ $trailingSlash = $trailingSlash === true ? '/' : null;
+
+ return $leadingSlash . $params . $trailingSlash;
+ }
+}
diff --git a/kirby/src/Http/Path.php b/kirby/src/Http/Path.php
new file mode 100755
index 0000000..eaa014a
--- /dev/null
+++ b/kirby/src/Http/Path.php
@@ -0,0 +1,47 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Path extends Collection
+{
+ public function __construct($items)
+ {
+ if (is_string($items) === true) {
+ $items = Str::split($items, '/');
+ }
+
+ parent::__construct($items ?? []);
+ }
+
+ public function __toString(): string
+ {
+ return $this->toString();
+ }
+
+ public function toString(bool $leadingSlash = false, bool $trailingSlash = false): string
+ {
+ if (empty($this->data) === true) {
+ return '';
+ }
+
+ $path = implode('/', $this->data);
+
+ $leadingSlash = $leadingSlash === true ? '/' : null;
+ $trailingSlash = $trailingSlash === true ? '/' : null;
+
+ return $leadingSlash . $path . $trailingSlash;
+ }
+}
diff --git a/kirby/src/Http/Query.php b/kirby/src/Http/Query.php
new file mode 100755
index 0000000..0956802
--- /dev/null
+++ b/kirby/src/Http/Query.php
@@ -0,0 +1,58 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Query extends Obj
+{
+ public function __construct($query)
+ {
+ if (is_string($query) === true) {
+ parse_str(ltrim($query, '?'), $query);
+ }
+
+ parent::__construct($query ?? []);
+ }
+
+ public function isEmpty(): bool
+ {
+ return empty((array)$this) === true;
+ }
+
+ public function isNotEmpty(): bool
+ {
+ return empty((array)$this) === false;
+ }
+
+ public function __toString(): string
+ {
+ return $this->toString();
+ }
+
+ public function toString($questionMark = false): string
+ {
+ $query = http_build_query($this);
+
+ if (empty($query) === true) {
+ return '';
+ }
+
+ if ($questionMark === true) {
+ $query = '?' . $query;
+ }
+
+ return $query;
+ }
+}
diff --git a/kirby/src/Http/Remote.php b/kirby/src/Http/Remote.php
new file mode 100755
index 0000000..c5ab761
--- /dev/null
+++ b/kirby/src/Http/Remote.php
@@ -0,0 +1,367 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Remote
+{
+ /**
+ * @var array
+ */
+ public static $defaults = [
+ 'agent' => null,
+ 'basicAuth' => null,
+ 'body' => true,
+ 'data' => [],
+ 'encoding' => 'utf-8',
+ 'file' => null,
+ 'headers' => [],
+ 'method' => 'GET',
+ 'progress' => null,
+ 'test' => false,
+ 'timeout' => 10,
+ ];
+
+ /**
+ * @var string
+ */
+ public $content;
+
+ /**
+ * @var resource
+ */
+ public $curl;
+
+ /**
+ * @var array
+ */
+ public $curlopt = [];
+
+ /**
+ * @var int
+ */
+ public $errorCode;
+
+ /**
+ * @var string
+ */
+ public $errorMessage;
+
+ /**
+ * @var array
+ */
+ public $headers = [];
+
+ /**
+ * @var array
+ */
+ public $info = [];
+
+ /**
+ * @var array
+ */
+ public $options = [];
+
+ /**
+ * Magic getter for request info data
+ *
+ * @param string $method
+ * @param array $arguments
+ * @return mixed
+ */
+ public function __call(string $method, array $arguments = [])
+ {
+ $method = str_replace('-', '_', Str::kebab($method));
+ return $this->info[$method] ?? null;
+ }
+
+ /**
+ * Constructor
+ *
+ * @param string $url
+ * @param array $options
+ */
+ public function __construct(string $url, array $options = [])
+ {
+ // set all options
+ $this->options = array_merge(static::$defaults, $options);
+
+ // add the url
+ $this->options['url'] = $url;
+
+ // send the request
+ $this->fetch();
+ }
+
+ public static function __callStatic(string $method, array $arguments = [])
+ {
+ return new static($arguments[0], array_merge(['method' => strtoupper($method)], $arguments[1] ?? []));
+ }
+
+ /**
+ * Returns the http status code
+ *
+ * @return int|null
+ */
+ public function code(): ?int
+ {
+ return $this->info['http_code'] ?? null;
+ }
+
+ /**
+ * Returns the response content
+ *
+ * @return mixed
+ */
+ public function content()
+ {
+ return $this->content;
+ }
+
+ /**
+ * Sets up all curl options and sends the request
+ *
+ * @return self
+ */
+ public function fetch()
+ {
+
+ // curl options
+ $this->curlopt = [
+ CURLOPT_URL => $this->options['url'],
+ CURLOPT_ENCODING => $this->options['encoding'],
+ CURLOPT_CONNECTTIMEOUT => $this->options['timeout'],
+ CURLOPT_TIMEOUT => $this->options['timeout'],
+ CURLOPT_AUTOREFERER => true,
+ CURLOPT_RETURNTRANSFER => $this->options['body'],
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_MAXREDIRS => 10,
+ CURLOPT_SSL_VERIFYPEER => false,
+ CURLOPT_HEADER => false,
+ CURLOPT_HEADERFUNCTION => function ($curl, $header) {
+ $parts = Str::split($header, ':');
+
+ if (empty($parts[0]) === false && empty($parts[1]) === false) {
+ $key = array_shift($parts);
+ $this->headers[$key] = implode(':', $parts);
+ }
+
+ return strlen($header);
+ }
+ ];
+
+ // add the progress
+ if (is_callable($this->options['progress']) === true) {
+ $this->curlopt[CURLOPT_NOPROGRESS] = false;
+ $this->curlopt[CURLOPT_PROGRESSFUNCTION] = $this->options['progress'];
+ }
+
+ // add all headers
+ if (empty($this->options['headers']) === false) {
+ // convert associative arrays to strings
+ $headers = [];
+ foreach ($this->options['headers'] as $key => $value) {
+ if (is_string($key) === true) {
+ $headers[] = $key . ': ' . $value;
+ } else {
+ $headers[] = $value;
+ }
+ }
+
+ $this->curlopt[CURLOPT_HTTPHEADER] = $headers;
+ }
+
+ // add HTTP Basic authentication
+ if (empty($this->options['basicAuth']) === false) {
+ $this->curlopt[CURLOPT_USERPWD] = $this->options['basicAuth'];
+ }
+
+ // add the user agent
+ if (empty($this->options['agent']) === false) {
+ $this->curlopt[CURLOPT_USERAGENT] = $this->options['agent'];
+ }
+
+ // do some request specific stuff
+ switch (strtoupper($this->options['method'])) {
+ case 'POST':
+ $this->curlopt[CURLOPT_POST] = true;
+ $this->curlopt[CURLOPT_CUSTOMREQUEST] = 'POST';
+ $this->curlopt[CURLOPT_POSTFIELDS] = $this->postfields($this->options['data']);
+ break;
+ case 'PUT':
+ $this->curlopt[CURLOPT_CUSTOMREQUEST] = 'PUT';
+ $this->curlopt[CURLOPT_POSTFIELDS] = $this->postfields($this->options['data']);
+
+ // put a file
+ if ($this->options['file']) {
+ $this->curlopt[CURLOPT_INFILE] = fopen($this->options['file'], 'r');
+ $this->curlopt[CURLOPT_INFILESIZE] = F::size($this->options['file']);
+ }
+ break;
+ case 'PATCH':
+ $this->curlopt[CURLOPT_CUSTOMREQUEST] = 'PATCH';
+ $this->curlopt[CURLOPT_POSTFIELDS] = $this->postfields($this->options['data']);
+ break;
+ case 'DELETE':
+ $this->curlopt[CURLOPT_CUSTOMREQUEST] = 'DELETE';
+ $this->curlopt[CURLOPT_POSTFIELDS] = $this->postfields($this->options['data']);
+ break;
+ case 'HEAD':
+ $this->curlopt[CURLOPT_CUSTOMREQUEST] = 'HEAD';
+ $this->curlopt[CURLOPT_POSTFIELDS] = $this->postfields($this->options['data']);
+ $this->curlopt[CURLOPT_NOBODY] = true;
+ break;
+ }
+
+ if ($this->options['test'] === true) {
+ return $this;
+ }
+
+ // start a curl request
+ $this->curl = curl_init();
+
+ curl_setopt_array($this->curl, $this->curlopt);
+
+ $this->content = curl_exec($this->curl);
+ $this->info = curl_getinfo($this->curl);
+ $this->errorCode = curl_errno($this->curl);
+ $this->errorMessage = curl_error($this->curl);
+
+ if ($this->errorCode) {
+ throw new Exception($this->errorMessage, $this->errorCode);
+ }
+
+ curl_close($this->curl);
+
+ return $this;
+ }
+
+ /**
+ * Static method to send a GET request
+ *
+ * @param string $url
+ * @param array $params
+ * @return self
+ */
+ public static function get(string $url, array $params = [])
+ {
+ $defaults = [
+ 'method' => 'GET',
+ 'data' => [],
+ ];
+
+ $options = array_merge($defaults, $params);
+ $query = http_build_query($options['data']);
+
+ if (empty($query) === false) {
+ $url = Url::hasQuery($url) === true ? $url . '&' . $query : $url . '?' . $query;
+ }
+
+ // remove the data array from the options
+ unset($options['data']);
+
+ return new static($url, $options);
+ }
+
+ /**
+ * Returns all received headers
+ *
+ * @return array
+ */
+ public function headers(): array
+ {
+ return $this->headers;
+ }
+
+ /**
+ * Returns the request info
+ *
+ * @return array
+ */
+ public function info(): array
+ {
+ return $this->info;
+ }
+
+ /**
+ * Decode the response content
+ *
+ * @param bool $array decode as array or object
+ * @return array|\stdClass
+ */
+ public function json(bool $array = true)
+ {
+ return json_decode($this->content(), $array);
+ }
+
+ /**
+ * Returns the request method
+ *
+ * @return string
+ */
+ public function method(): string
+ {
+ return $this->options['method'];
+ }
+
+ /**
+ * Returns all options which have been
+ * set for the current request
+ *
+ * @return array
+ */
+ public function options(): array
+ {
+ return $this->options;
+ }
+
+ /**
+ * Internal method to handle post field data
+ *
+ * @param mixed $data
+ * @return mixed
+ */
+ protected function postfields($data)
+ {
+ if (is_object($data) || is_array($data)) {
+ return http_build_query($data);
+ } else {
+ return $data;
+ }
+ }
+
+ /**
+ * Static method to init this class and send a request
+ *
+ * @param string $url
+ * @param array $params
+ * @return self
+ */
+ public static function request(string $url, array $params = [])
+ {
+ return new static($url, $params);
+ }
+
+ /**
+ * Returns the request Url
+ *
+ * @return string
+ */
+ public function url(): string
+ {
+ return $this->options['url'];
+ }
+}
diff --git a/kirby/src/Http/Request.php b/kirby/src/Http/Request.php
new file mode 100755
index 0000000..c83645a
--- /dev/null
+++ b/kirby/src/Http/Request.php
@@ -0,0 +1,380 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Request
+{
+ /**
+ * The auth object if available
+ *
+ * @var BearerAuth|BasicAuth|false|null
+ */
+ protected $auth;
+
+ /**
+ * The Body object is a wrapper around
+ * the request body, which parses the contents
+ * of the body and provides an API to fetch
+ * particular parts of the body
+ *
+ * Examples:
+ *
+ * `$request->body()->get('foo')`
+ *
+ * @var Body
+ */
+ protected $body;
+
+ /**
+ * The Files object is a wrapper around
+ * the $_FILES global. It sanitizes the
+ * $_FILES array and provides an API to fetch
+ * individual files by key
+ *
+ * Examples:
+ *
+ * `$request->files()->get('upload')['size']`
+ * `$request->file('upload')['size']`
+ *
+ * @var Files
+ */
+ protected $files;
+
+ /**
+ * The Method type
+ *
+ * @var string
+ */
+ protected $method;
+
+ /**
+ * All options that have been passed to
+ * the request in the constructor
+ *
+ * @var array
+ */
+ protected $options;
+
+ /**
+ * The Query object is a wrapper around
+ * the URL query string, which parses the
+ * string and provides a clean API to fetch
+ * particular parts of the query
+ *
+ * Examples:
+ *
+ * `$request->query()->get('foo')`
+ *
+ * @var Query
+ */
+ protected $query;
+
+ /**
+ * Request URL object
+ *
+ * @var Uri
+ */
+ protected $url;
+
+ /**
+ * Creates a new Request object
+ * You can either pass your own request
+ * data via the $options array or use
+ * the data from the incoming request.
+ *
+ * @param array $options
+ */
+ public function __construct(array $options = [])
+ {
+ $this->options = $options;
+ $this->method = $options['method'] ?? $_SERVER['REQUEST_METHOD'] ?? 'GET';
+
+ if (isset($options['body']) === true) {
+ $this->body = new Body($options['body']);
+ }
+
+ if (isset($options['files']) === true) {
+ $this->files = new Files($options['files']);
+ }
+
+ if (isset($options['query']) === true) {
+ $this->query = new Query($options['query']);
+ }
+
+ if (isset($options['url']) === true) {
+ $this->url = new Uri($options['url']);
+ }
+ }
+
+ /**
+ * Improved `var_dump` output
+ *
+ * @return array
+ */
+ public function __debugInfo(): array
+ {
+ return [
+ 'body' => $this->body(),
+ 'files' => $this->files(),
+ 'method' => $this->method(),
+ 'query' => $this->query(),
+ 'url' => $this->url()->toString()
+ ];
+ }
+
+ /**
+ * Returns the Auth object if authentication is set
+ *
+ * @return \Kirby\Http\Request\Auth\BasicAuth|\Kirby\Http\Request\Auth\BearerAuth|null
+ */
+ public function auth()
+ {
+ if ($this->auth !== null) {
+ return $this->auth;
+ }
+
+ if ($auth = $this->options['auth'] ?? $this->header('authorization')) {
+ $type = Str::before($auth, ' ');
+ $token = Str::after($auth, ' ');
+ $class = 'Kirby\\Http\\Request\\Auth\\' . ucfirst($type) . 'Auth';
+
+ if (class_exists($class) === false) {
+ return $this->auth = false;
+ }
+
+ return $this->auth = new $class($token);
+ }
+
+ return $this->auth = false;
+ }
+
+ /**
+ * Returns the Body object
+ *
+ * @return \Kirby\Http\Request\Body
+ */
+ public function body()
+ {
+ return $this->body = $this->body ?? new Body();
+ }
+
+ /**
+ * Checks if the request has been made from the command line
+ *
+ * @return bool
+ */
+ public function cli(): bool
+ {
+ return Server::cli();
+ }
+
+ /**
+ * Returns a CSRF token if stored in a header or the query
+ *
+ * @return string|null
+ */
+ public function csrf(): ?string
+ {
+ return $this->header('x-csrf') ?? $this->query()->get('csrf');
+ }
+
+ /**
+ * Returns the request input as array
+ *
+ * @return array
+ */
+ public function data(): array
+ {
+ return array_merge($this->body()->toArray(), $this->query()->toArray());
+ }
+
+ /**
+ * Returns the domain
+ *
+ * @return string
+ */
+ public function domain(): string
+ {
+ return $this->url()->domain();
+ }
+
+ /**
+ * Fetches a single file array
+ * from the Files object by key
+ *
+ * @param string $key
+ * @return array|null
+ */
+ public function file(string $key)
+ {
+ return $this->files()->get($key);
+ }
+
+ /**
+ * Returns the Files object
+ *
+ * @return \Kirby\Cms\Files
+ */
+ public function files()
+ {
+ return $this->files = $this->files ?? new Files();
+ }
+
+ /**
+ * Returns any data field from the request
+ * if it exists
+ *
+ * @param string|null|array $key
+ * @param mixed $fallback
+ * @return mixed
+ */
+ public function get($key = null, $fallback = null)
+ {
+ return A::get($this->data(), $key, $fallback);
+ }
+
+ /**
+ * Returns a header by key if it exists
+ *
+ * @param string $key
+ * @param mixed $fallback
+ * @return mixed
+ */
+ public function header(string $key, $fallback = null)
+ {
+ $headers = array_change_key_case($this->headers());
+ return $headers[strtolower($key)] ?? $fallback;
+ }
+
+ /**
+ * Return all headers with polyfill for
+ * missing getallheaders function
+ *
+ * @return array
+ */
+ public function headers(): array
+ {
+ $headers = [];
+
+ foreach ($_SERVER as $key => $value) {
+ if (substr($key, 0, 5) !== 'HTTP_' && substr($key, 0, 14) !== 'REDIRECT_HTTP_') {
+ continue;
+ }
+
+ // remove HTTP_
+ $key = str_replace(['REDIRECT_HTTP_', 'HTTP_'], '', $key);
+
+ // convert to lowercase
+ $key = strtolower($key);
+
+ // replace _ with spaces
+ $key = str_replace('_', ' ', $key);
+
+ // uppercase first char in each word
+ $key = ucwords($key);
+
+ // convert spaces to dashes
+ $key = str_replace(' ', '-', $key);
+
+ $headers[$key] = $value;
+ }
+
+ return $headers;
+ }
+
+ /**
+ * Checks if the given method name
+ * matches the name of the request method.
+ *
+ * @param string $method
+ * @return bool
+ */
+ public function is(string $method): bool
+ {
+ return strtoupper($this->method) === strtoupper($method);
+ }
+
+ /**
+ * Returns the request method
+ *
+ * @return string
+ */
+ public function method(): string
+ {
+ return $this->method;
+ }
+
+ /**
+ * Shortcut to the Params object
+ */
+ public function params()
+ {
+ return $this->url()->params();
+ }
+
+ /**
+ * Shortcut to the Path object
+ */
+ public function path()
+ {
+ return $this->url()->path();
+ }
+
+ /**
+ * Returns the Query object
+ *
+ * @return \Kirby\Http\Query
+ */
+ public function query()
+ {
+ return $this->query = $this->query ?? new Query();
+ }
+
+ /**
+ * Checks for a valid SSL connection
+ *
+ * @return bool
+ */
+ public function ssl(): bool
+ {
+ return $this->url()->scheme() === 'https';
+ }
+
+ /**
+ * Returns the current Uri object.
+ * If you pass props you can safely modify
+ * the Url with new parameters without destroying
+ * the original object.
+ *
+ * @param array $props
+ * @return \Kirby\Http\Uri
+ */
+ public function url(array $props = null)
+ {
+ if ($props !== null) {
+ return $this->url()->clone($props);
+ }
+
+ return $this->url = $this->url ?? Uri::current();
+ }
+}
diff --git a/kirby/src/Http/Request/Auth/BasicAuth.php b/kirby/src/Http/Request/Auth/BasicAuth.php
new file mode 100755
index 0000000..4df6e8f
--- /dev/null
+++ b/kirby/src/Http/Request/Auth/BasicAuth.php
@@ -0,0 +1,78 @@
+credentials = base64_decode($token);
+ $this->username = Str::before($this->credentials, ':');
+ $this->password = Str::after($this->credentials, ':');
+ }
+
+ /**
+ * Returns the entire unencoded credentials string
+ *
+ * @return string
+ */
+ public function credentials(): string
+ {
+ return $this->credentials;
+ }
+
+ /**
+ * Returns the password
+ *
+ * @return string|null
+ */
+ public function password(): ?string
+ {
+ return $this->password;
+ }
+
+ /**
+ * Returns the authentication type
+ *
+ * @return string
+ */
+ public function type(): string
+ {
+ return 'basic';
+ }
+
+ /**
+ * Returns the username
+ *
+ * @return string|null
+ */
+ public function username(): ?string
+ {
+ return $this->username;
+ }
+}
diff --git a/kirby/src/Http/Request/Auth/BearerAuth.php b/kirby/src/Http/Request/Auth/BearerAuth.php
new file mode 100755
index 0000000..2c5b1c2
--- /dev/null
+++ b/kirby/src/Http/Request/Auth/BearerAuth.php
@@ -0,0 +1,54 @@
+token = $token;
+ }
+
+ /**
+ * Converts the object to a string
+ *
+ * @return string
+ */
+ public function __toString(): string
+ {
+ return ucfirst($this->type()) . ' ' . $this->token();
+ }
+
+ /**
+ * Returns the authentication token
+ *
+ * @return string
+ */
+ public function token(): string
+ {
+ return $this->token;
+ }
+
+ /**
+ * Returns the auth type
+ *
+ * @return string
+ */
+ public function type(): string
+ {
+ return 'bearer';
+ }
+}
diff --git a/kirby/src/Http/Request/Body.php b/kirby/src/Http/Request/Body.php
new file mode 100755
index 0000000..f9b6692
--- /dev/null
+++ b/kirby/src/Http/Request/Body.php
@@ -0,0 +1,129 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Body
+{
+ use Data;
+
+ /**
+ * The raw body content
+ *
+ * @var string|array
+ */
+ protected $contents;
+
+ /**
+ * The parsed content as array
+ *
+ * @var array
+ */
+ protected $data;
+
+ /**
+ * Creates a new request body object.
+ * You can pass your own array or string.
+ * If null is being passed, the class will
+ * fetch the body either from the $_POST global
+ * or from php://input.
+ *
+ * @param array|string|null $contents
+ */
+ public function __construct($contents = null)
+ {
+ $this->contents = $contents;
+ }
+
+ /**
+ * Fetches the raw contents for the body
+ * or uses the passed contents.
+ *
+ * @return string|array
+ */
+ public function contents()
+ {
+ if ($this->contents === null) {
+ if (empty($_POST) === false) {
+ $this->contents = $_POST;
+ } else {
+ $this->contents = file_get_contents('php://input');
+ }
+ }
+
+ return $this->contents;
+ }
+
+ /**
+ * Parses the raw contents once and caches
+ * the result. The parser will try to convert
+ * the body with the json decoder first and
+ * then run parse_str to get some results
+ * if the json decoder failed.
+ *
+ * @return array
+ */
+ public function data(): array
+ {
+ if (is_array($this->data) === true) {
+ return $this->data;
+ }
+
+ $contents = $this->contents();
+
+ // return content which is already in array form
+ if (is_array($contents) === true) {
+ return $this->data = $contents;
+ }
+
+ // try to convert the body from json
+ $json = json_decode($contents, true);
+
+ if (is_array($json) === true) {
+ return $this->data = $json;
+ }
+
+ if (strstr($contents, '=') !== false) {
+ // try to parse the body as query string
+ parse_str($contents, $parsed);
+
+ if (is_array($parsed)) {
+ return $this->data = $parsed;
+ }
+ }
+
+ return $this->data = [];
+ }
+
+ /**
+ * Converts the data array back
+ * to a http query string
+ *
+ * @return string
+ */
+ public function toString(): string
+ {
+ return http_build_query($this->data());
+ }
+
+ /**
+ * Magic string converter
+ *
+ * @return string
+ */
+ public function __toString(): string
+ {
+ return $this->toString();
+ }
+}
diff --git a/kirby/src/Http/Request/Data.php b/kirby/src/Http/Request/Data.php
new file mode 100755
index 0000000..0a6bc7f
--- /dev/null
+++ b/kirby/src/Http/Request/Data.php
@@ -0,0 +1,84 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+trait Data
+{
+ /**
+ * Improved `var_dump` output
+ *
+ * @return array
+ */
+ public function __debugInfo(): array
+ {
+ return $this->toArray();
+ }
+
+ /**
+ * The data provider method has to be
+ * implemented by each class using this Trait
+ * and has to return an associative array
+ * for the get method
+ *
+ * @return array
+ */
+ abstract public function data(): array;
+
+ /**
+ * The get method is the heart and soul of this
+ * Trait. You can use it to fetch a single value
+ * of the data array by key or multiple values by
+ * passing an array of keys.
+ *
+ * @param string|array $key
+ * @param mixed|null $default
+ * @return mixed
+ */
+ public function get($key, $default = null)
+ {
+ if (is_array($key) === true) {
+ $result = [];
+ foreach ($key as $k) {
+ $result[$k] = $this->get($k);
+ }
+ return $result;
+ }
+
+ return $this->data()[$key] ?? $default;
+ }
+
+ /**
+ * Returns the data array.
+ * This is basically an alias for Data::data()
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->data();
+ }
+
+ /**
+ * Converts the data array to json
+ *
+ * @return string
+ */
+ public function toJson(): string
+ {
+ return json_encode($this->data());
+ }
+}
diff --git a/kirby/src/Http/Request/Files.php b/kirby/src/Http/Request/Files.php
new file mode 100755
index 0000000..d5304dd
--- /dev/null
+++ b/kirby/src/Http/Request/Files.php
@@ -0,0 +1,73 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Files
+{
+ use Data;
+
+ /**
+ * Sanitized array of all received files
+ *
+ * @var array
+ */
+ protected $files;
+
+ /**
+ * Creates a new Files object
+ * Pass your own array to mock
+ * uploads.
+ *
+ * @param array|null $files
+ */
+ public function __construct($files = null)
+ {
+ if ($files === null) {
+ $files = $_FILES;
+ }
+
+ $this->files = [];
+
+ foreach ($files as $key => $file) {
+ if (is_array($file['name'])) {
+ foreach ($file['name'] as $i => $name) {
+ $this->files[$key][] = [
+ 'name' => $file['name'][$i] ?? null,
+ 'type' => $file['type'][$i] ?? null,
+ 'tmp_name' => $file['tmp_name'][$i] ?? null,
+ 'error' => $file['error'][$i] ?? null,
+ 'size' => $file['size'][$i] ?? null,
+ ];
+ }
+ } else {
+ $this->files[$key] = $file;
+ }
+ }
+ }
+
+ /**
+ * The data method returns the files
+ * array. This is only needed to make
+ * the Data trait work for the Files::get($key)
+ * method.
+ *
+ * @return array
+ */
+ public function data(): array
+ {
+ return $this->files;
+ }
+}
diff --git a/kirby/src/Http/Request/Query.php b/kirby/src/Http/Request/Query.php
new file mode 100755
index 0000000..946218d
--- /dev/null
+++ b/kirby/src/Http/Request/Query.php
@@ -0,0 +1,78 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Query
+{
+ use Data;
+
+ /**
+ * The Query data array
+ *
+ * @var array|null
+ */
+ protected $data;
+
+ /**
+ * Creates a new Query object.
+ * The passed data can be an array
+ * or a parsable query string. If
+ * null is passed, the current Query
+ * will be taken from $_GET
+ *
+ * @param array|string|null $data
+ */
+ public function __construct($data = null)
+ {
+ if ($data === null) {
+ $this->data = $_GET;
+ } elseif (is_array($data)) {
+ $this->data = $data;
+ } else {
+ parse_str($data, $parsed);
+ $this->data = $parsed;
+ }
+ }
+
+ /**
+ * Returns the Query data as array
+ *
+ * @return array
+ */
+ public function data(): array
+ {
+ return $this->data;
+ }
+
+ /**
+ * Converts the query data array
+ * back to a query string
+ *
+ * @return string
+ */
+ public function toString(): string
+ {
+ return http_build_query($this->data());
+ }
+
+ /**
+ * Magic string converter
+ *
+ * @return string
+ */
+ public function __toString(): string
+ {
+ return $this->toString();
+ }
+}
diff --git a/kirby/src/Http/Response.php b/kirby/src/Http/Response.php
new file mode 100755
index 0000000..ca7596a
--- /dev/null
+++ b/kirby/src/Http/Response.php
@@ -0,0 +1,308 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Response
+{
+ /**
+ * Store for all registered headers,
+ * which will be sent with the response
+ *
+ * @var array
+ */
+ protected $headers = [];
+
+ /**
+ * The response body
+ *
+ * @var string
+ */
+ protected $body;
+
+ /**
+ * The HTTP response code
+ *
+ * @var int
+ */
+ protected $code;
+
+ /**
+ * The content type for the response
+ *
+ * @var string
+ */
+ protected $type;
+
+ /**
+ * The content type charset
+ *
+ * @var string
+ */
+ protected $charset = 'UTF-8';
+
+ /**
+ * Creates a new response object
+ *
+ * @param string $body
+ * @param string $type
+ * @param int $code
+ * @param array $headers
+ * @param string $charset
+ */
+ public function __construct($body = '', ?string $type = null, ?int $code = null, ?array $headers = null, ?string $charset = null)
+ {
+ // array construction
+ if (is_array($body) === true) {
+ $params = $body;
+ $body = $params['body'] ?? '';
+ $type = $params['type'] ?? $type;
+ $code = $params['code'] ?? $code;
+ $headers = $params['headers'] ?? $headers;
+ $charset = $params['charset'] ?? $charset;
+ }
+
+ // regular construction
+ $this->body = $body;
+ $this->type = $type ?? 'text/html';
+ $this->code = $code ?? 200;
+ $this->headers = $headers ?? [];
+ $this->charset = $charset ?? 'UTF-8';
+
+ // automatic mime type detection
+ if (strpos($this->type, '/') === false) {
+ $this->type = F::extensionToMime($this->type) ?? 'text/html';
+ }
+ }
+
+ /**
+ * Improved `var_dump` output
+ *
+ * @return array
+ */
+ public function __debugInfo(): array
+ {
+ return $this->toArray();
+ }
+
+ /**
+ * Makes it possible to convert the
+ * entire response object to a string
+ * to send the headers and print the body
+ *
+ * @return string
+ */
+ public function __toString(): string
+ {
+ try {
+ return $this->send();
+ } catch (Throwable $e) {
+ return '';
+ }
+ }
+
+ /**
+ * Getter for the body
+ *
+ * @return string
+ */
+ public function body(): string
+ {
+ return $this->body;
+ }
+
+ /**
+ * Getter for the content type charset
+ *
+ * @return string
+ */
+ public function charset(): string
+ {
+ return $this->charset;
+ }
+
+ /**
+ * Getter for the HTTP status code
+ *
+ * @return int
+ */
+ public function code(): int
+ {
+ return $this->code;
+ }
+
+ /**
+ * Creates a response that triggers
+ * a file download for the given file
+ *
+ * @param string $file
+ * @param string $filename
+ * @return self
+ */
+ public static function download(string $file, string $filename = null)
+ {
+ if (file_exists($file) === false) {
+ throw new Exception('The file could not be found');
+ }
+
+ $filename = $filename ?? basename($file);
+ $modified = filemtime($file);
+ $body = file_get_contents($file);
+ $size = strlen($body);
+
+ return new static([
+ 'body' => $body,
+ 'type' => 'application/force-download',
+ 'headers' => [
+ 'Pragma' => 'public',
+ 'Expires' => '0',
+ 'Last-Modified' => gmdate('D, d M Y H:i:s', $modified) . ' GMT',
+ 'Content-Disposition' => 'attachment; filename="' . $filename . '"',
+ 'Content-Transfer-Encoding' => 'binary',
+ 'Content-Length' => $size,
+ 'Connection' => 'close'
+ ]
+ ]);
+ }
+
+ /**
+ * Creates a response for a file and
+ * sends the file content to the browser
+ *
+ * @param string $file
+ * @return self
+ */
+ public static function file(string $file)
+ {
+ return new static(F::read($file), F::extensionToMime(F::extension($file)));
+ }
+
+ /**
+ * Getter for single headers
+ *
+ * @param string $key Name of the header
+ * @return string|null
+ */
+ public function header(string $key): ?string
+ {
+ return $this->headers[$key] ?? null;
+ }
+
+ /**
+ * Getter for all headers
+ *
+ * @return array
+ */
+ public function headers(): array
+ {
+ return $this->headers;
+ }
+
+ /**
+ * Creates a json response with appropriate
+ * header and automatic conversion of arrays.
+ *
+ * @param string|array $body
+ * @param int $code
+ * @param bool $pretty
+ * @param array $headers
+ * @return self
+ */
+ public static function json($body = '', ?int $code = null, ?bool $pretty = null, array $headers = [])
+ {
+ if (is_array($body) === true) {
+ $body = json_encode($body, $pretty === true ? JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES : null);
+ }
+
+ return new static([
+ 'body' => $body,
+ 'code' => $code,
+ 'type' => 'application/json',
+ 'headers' => $headers
+ ]);
+ }
+
+ /**
+ * Creates a redirect response,
+ * which will send the visitor to the
+ * given location.
+ *
+ * @param string $location
+ * @param int $code
+ * @return self
+ */
+ public static function redirect(?string $location = null, ?int $code = null)
+ {
+ return new static([
+ 'code' => $code ?? 302,
+ 'headers' => [
+ 'Location' => Url::unIdn($location ?? '/')
+ ]
+ ]);
+ }
+
+ /**
+ * Sends all registered headers and
+ * returns the response body
+ *
+ * @return string
+ */
+ public function send(): string
+ {
+ // send the status response code
+ http_response_code($this->code());
+
+ // send all custom headers
+ foreach ($this->headers() as $key => $value) {
+ header($key . ': ' . $value);
+ }
+
+ // send the content type header
+ header('Content-Type:' . $this->type() . '; charset=' . $this->charset());
+
+ // print the response body
+ return $this->body();
+ }
+
+ /**
+ * Converts all relevant response attributes
+ * to an associative array for debugging,
+ * testing or whatever.
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return [
+ 'type' => $this->type(),
+ 'charset' => $this->charset(),
+ 'code' => $this->code(),
+ 'headers' => $this->headers(),
+ 'body' => $this->body()
+ ];
+ }
+
+ /**
+ * Getter for the content type
+ *
+ * @return string
+ */
+ public function type(): string
+ {
+ return $this->type;
+ }
+}
diff --git a/kirby/src/Http/Route.php b/kirby/src/Http/Route.php
new file mode 100755
index 0000000..eff1891
--- /dev/null
+++ b/kirby/src/Http/Route.php
@@ -0,0 +1,230 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Route
+{
+ /**
+ * The callback action function
+ *
+ * @var Closure
+ */
+ protected $action;
+
+ /**
+ * Listed of parsed arguments
+ *
+ * @var array
+ */
+ protected $arguments = [];
+
+ /**
+ * An array of all passed attributes
+ *
+ * @var array
+ */
+ protected $attributes = [];
+
+ /**
+ * The registered request method
+ *
+ * @var string
+ */
+ protected $method;
+
+ /**
+ * The registered pattern
+ *
+ * @var string
+ */
+ protected $pattern;
+
+ /**
+ * Wildcards, which can be used in
+ * Route patterns to make regular expressions
+ * a little more human
+ *
+ * @var array
+ */
+ protected $wildcards = [
+ 'required' => [
+ '(:num)' => '(-?[0-9]+)',
+ '(:alpha)' => '([a-zA-Z]+)',
+ '(:alphanum)' => '([a-zA-Z0-9]+)',
+ '(:any)' => '([a-zA-Z0-9\.\-_%= \+\@\(\)]+)',
+ '(:all)' => '(.*)',
+ ],
+ 'optional' => [
+ '/(:num?)' => '(?:/(-?[0-9]+)',
+ '/(:alpha?)' => '(?:/([a-zA-Z]+)',
+ '/(:alphanum?)' => '(?:/([a-zA-Z0-9]+)',
+ '/(:any?)' => '(?:/([a-zA-Z0-9\.\-_%= \+\@\(\)]+)',
+ '/(:all?)' => '(?:/(.*)',
+ ],
+ ];
+
+ /**
+ * Magic getter for route attributes
+ *
+ * @param string $key
+ * @param array $arguments
+ * @return mixed
+ */
+ public function __call(string $key, array $arguments = null)
+ {
+ return $this->attributes[$key] ?? null;
+ }
+
+ /**
+ * Creates a new Route object for the given
+ * pattern(s), method(s) and the callback action
+ *
+ * @param string|array $pattern
+ * @param string|array $method
+ * @param Closure $action
+ * @param array $attributes
+ */
+ public function __construct($pattern, $method = 'GET', Closure $action, array $attributes = [])
+ {
+ $this->action = $action;
+ $this->attributes = $attributes;
+ $this->method = $method;
+ $this->pattern = $this->regex(ltrim($pattern, '/'));
+ }
+
+ /**
+ * Getter for the action callback
+ *
+ * @return Closure
+ */
+ public function action()
+ {
+ return $this->action;
+ }
+
+ /**
+ * Returns all parsed arguments
+ *
+ * @return array
+ */
+ public function arguments(): array
+ {
+ return $this->arguments;
+ }
+
+ /**
+ * Getter for additional attributes
+ *
+ * @return array
+ */
+ public function attributes(): array
+ {
+ return $this->attributes;
+ }
+
+ /**
+ * Getter for the method
+ *
+ * @return string
+ */
+ public function method(): string
+ {
+ return $this->method;
+ }
+
+ /**
+ * Returns the route name if set
+ *
+ * @return string|null
+ */
+ public function name(): ?string
+ {
+ return $this->attributes['name'] ?? null;
+ }
+
+ /**
+ * Throws a specific exception to tell
+ * the router to jump to the next route
+ * @since 3.0.3
+ *
+ * @return void
+ */
+ public function next(): void
+ {
+ throw new Exceptions\NextRouteException('next');
+ }
+
+ /**
+ * Getter for the pattern
+ *
+ * @return string
+ */
+ public function pattern(): string
+ {
+ return $this->pattern;
+ }
+
+ /**
+ * Converts the pattern into a full regular
+ * expression by replacing all the wildcards
+ *
+ * @param string $pattern
+ * @return string
+ */
+ public function regex(string $pattern): string
+ {
+ $search = array_keys($this->wildcards['optional']);
+ $replace = array_values($this->wildcards['optional']);
+
+ // For optional parameters, first translate the wildcards to their
+ // regex equivalent, sans the ")?" ending. We'll add the endings
+ // back on when we know the replacement count.
+ $pattern = str_replace($search, $replace, $pattern, $count);
+
+ if ($count > 0) {
+ $pattern .= str_repeat(')?', $count);
+ }
+
+ return strtr($pattern, $this->wildcards['required']);
+ }
+
+ /**
+ * Tries to match the path with the regular expression and
+ * extracts all arguments for the Route action
+ *
+ * @param string $pattern
+ * @param string $path
+ * @return array|false
+ */
+ public function parse(string $pattern, string $path)
+ {
+ // check for direct matches
+ if ($pattern === $path) {
+ return $this->arguments = [];
+ }
+
+ // We only need to check routes with regular expression since all others
+ // would have been able to be matched by the search for literal matches
+ // we just did before we started searching.
+ if (strpos($pattern, '(') === false) {
+ return false;
+ }
+
+ // If we have a match we'll return all results
+ // from the preg without the full first match.
+ if (preg_match('#^' . $this->regex($pattern) . '$#u', $path, $parameters)) {
+ return $this->arguments = array_slice($parameters, 1);
+ }
+
+ return false;
+ }
+}
diff --git a/kirby/src/Http/Router.php b/kirby/src/Http/Router.php
new file mode 100755
index 0000000..90903f4
--- /dev/null
+++ b/kirby/src/Http/Router.php
@@ -0,0 +1,168 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Router
+{
+ public static $beforeEach;
+ public static $afterEach;
+
+ /**
+ * Store for the current route,
+ * if one can be found
+ *
+ * @var Route|null
+ */
+ protected $route;
+
+ /**
+ * All registered routes, sorted by
+ * their request method. This makes
+ * it faster to find the right route
+ * later.
+ *
+ * @var array
+ */
+ protected $routes = [
+ 'GET' => [],
+ 'HEAD' => [],
+ 'POST' => [],
+ 'PUT' => [],
+ 'DELETE' => [],
+ 'CONNECT' => [],
+ 'OPTIONS' => [],
+ 'TRACE' => [],
+ 'PATCH' => [],
+ ];
+
+ /**
+ * Creates a new router object and
+ * registers all the given routes
+ *
+ * @param array $routes
+ */
+ public function __construct(array $routes = [])
+ {
+ foreach ($routes as $props) {
+ if (isset($props['pattern'], $props['action']) === false) {
+ throw new InvalidArgumentException('Invalid route parameters');
+ }
+
+ $methods = array_map('trim', explode('|', strtoupper($props['method'] ?? 'GET')));
+ $patterns = is_array($props['pattern']) === false ? [$props['pattern']] : $props['pattern'];
+
+ if ($methods === ['ALL']) {
+ $methods = array_keys($this->routes);
+ }
+
+ foreach ($methods as $method) {
+ foreach ($patterns as $pattern) {
+ $this->routes[$method][] = new Route($pattern, $method, $props['action'], $props);
+ }
+ }
+ }
+ }
+
+ /**
+ * Calls the Router by path and method.
+ * This will try to find a Route object
+ * and then call the Route action with
+ * the appropriate arguments and a Result
+ * object.
+ *
+ * @param string $path
+ * @param string $method
+ * @param Closure|null $callback
+ * @return mixed
+ */
+ public function call(string $path = null, string $method = 'GET', Closure $callback = null)
+ {
+ $path = $path ?? '';
+ $ignore = [];
+ $result = null;
+ $loop = true;
+
+ while ($loop === true) {
+ $route = $this->find($path, $method, $ignore);
+
+ if (is_a(static::$beforeEach, 'Closure') === true) {
+ (static::$beforeEach)($route, $path, $method);
+ }
+
+ try {
+ if ($callback) {
+ $result = $callback($route);
+ } else {
+ $result = $route->action()->call($route, ...$route->arguments());
+ }
+
+ $loop = false;
+ } catch (Exceptions\NextRouteException $e) {
+ $ignore[] = $route;
+ }
+
+ if (is_a(static::$afterEach, 'Closure') === true) {
+ $result = (static::$afterEach)($route, $path, $method, $result);
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Finds a Route object by path and method
+ * The Route's arguments method is used to
+ * find matches and return all the found
+ * arguments in the path.
+ *
+ * @param string $path
+ * @param string $method
+ * @param array $ignore
+ * @return \Kirby\Http\Route|null
+ */
+ public function find(string $path, string $method, array $ignore = null)
+ {
+ if (isset($this->routes[$method]) === false) {
+ throw new InvalidArgumentException('Invalid routing method: ' . $method, 400);
+ }
+
+ // remove leading and trailing slashes
+ $path = trim($path, '/');
+
+ foreach ($this->routes[$method] as $route) {
+ $arguments = $route->parse($route->pattern(), $path);
+
+ if ($arguments !== false) {
+ if (empty($ignore) === true || in_array($route, $ignore) === false) {
+ return $this->route = $route;
+ }
+ }
+ }
+
+ throw new Exception('No route found for path: "' . $path . '" and request method: "' . $method . '"', 404);
+ }
+
+ /**
+ * Returns the current route.
+ * This will only return something,
+ * once Router::find() has been called
+ * and only if a route was found.
+ *
+ * @return \Kirby\Http\Route|null
+ */
+ public function route()
+ {
+ return $this->route;
+ }
+}
diff --git a/kirby/src/Http/Server.php b/kirby/src/Http/Server.php
new file mode 100755
index 0000000..1ccf919
--- /dev/null
+++ b/kirby/src/Http/Server.php
@@ -0,0 +1,169 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Server
+{
+ /**
+ * Cache for the cli status
+ *
+ * @var bool|null
+ */
+ public static $cli;
+
+ /**
+ * Returns the server's IP address
+ *
+ * @return string
+ */
+ public static function address(): string
+ {
+ return static::get('SERVER_ADDR');
+ }
+
+ /**
+ * Checks if the request is being served by the CLI
+ *
+ * @return bool
+ */
+ public static function cli(): bool
+ {
+ if (static::$cli !== null) {
+ return static::$cli;
+ }
+
+ if (defined('STDIN') === true) {
+ return static::$cli = true;
+ }
+
+ $term = getenv('TERM');
+
+ if (substr(PHP_SAPI, 0, 3) === 'cgi' && $term && $term !== 'unknown') {
+ return static::$cli = true;
+ }
+
+ return static::$cli = false;
+ }
+
+ /**
+ * Gets a value from the _SERVER array
+ *
+ *
+ * Server::get('document_root');
+ * // sample output: /var/www/kirby
+ *
+ * Server::get();
+ * // returns the whole server array
+ *
+ *
+ * @param mixed $key The key to look for. Pass false or null to
+ * return the entire server array.
+ * @param mixed $default Optional default value, which should be
+ * returned if no element has been found
+ * @return mixed
+ */
+ public static function get($key = null, $default = null)
+ {
+ if ($key === null) {
+ return $_SERVER;
+ }
+
+ $key = strtoupper($key);
+ $value = $_SERVER[$key] ?? $default;
+ return static::sanitize($key, $value);
+ }
+
+ /**
+ * Help to sanitize some _SERVER keys
+ *
+ * @param string $key
+ * @param mixed $value
+ * @return mixed
+ */
+ public static function sanitize(string $key, $value)
+ {
+ switch ($key) {
+ case 'SERVER_ADDR':
+ case 'SERVER_NAME':
+ case 'HTTP_HOST':
+ case 'HTTP_X_FORWARDED_HOST':
+ $value = strip_tags($value);
+ $value = preg_replace('![^\w.:-]+!iu', '', $value);
+ $value = trim($value, '-');
+ $value = htmlspecialchars($value);
+ break;
+ case 'SERVER_PORT':
+ case 'HTTP_X_FORWARDED_PORT':
+ $value = (int)(preg_replace('![^0-9]+!', '', $value));
+ break;
+ }
+
+ return $value;
+ }
+
+ /**
+ * Returns the correct port number
+ *
+ * @param bool $forwarded
+ * @return int
+ */
+ public static function port(bool $forwarded = false): int
+ {
+ $port = $forwarded === true ? static::get('HTTP_X_FORWARDED_PORT') : null;
+
+ if (empty($port) === true) {
+ $port = static::get('SERVER_PORT');
+ }
+
+ return $port;
+ }
+
+ /**
+ * Checks for a https request
+ *
+ * @return bool
+ */
+ public static function https(): bool
+ {
+ if (isset($_SERVER['HTTPS']) && !empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off') {
+ return true;
+ } elseif (static::port() === 443) {
+ return true;
+ } elseif (in_array(static::get('HTTP_X_FORWARDED_PROTO'), ['https', 'https, http'])) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Returns the correct host
+ *
+ * @param bool $forwarded
+ * @return string
+ */
+ public static function host(bool $forwarded = false): string
+ {
+ $host = $forwarded === true ? static::get('HTTP_X_FORWARDED_HOST') : null;
+
+ if (empty($host) === true) {
+ $host = static::get('SERVER_NAME');
+ }
+
+ if (empty($host) === true) {
+ $host = static::get('SERVER_ADDR');
+ }
+
+ return explode(':', $host)[0];
+ }
+}
diff --git a/kirby/src/Http/Uri.php b/kirby/src/Http/Uri.php
new file mode 100755
index 0000000..190ae89
--- /dev/null
+++ b/kirby/src/Http/Uri.php
@@ -0,0 +1,563 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Uri
+{
+ use Properties;
+
+ /**
+ * Cache for the current Uri object
+ *
+ * @var Uri|null
+ */
+ public static $current;
+
+ /**
+ * The fragment after the hash
+ *
+ * @var string|false
+ */
+ protected $fragment;
+
+ /**
+ * The host address
+ *
+ * @var string
+ */
+ protected $host;
+
+ /**
+ * The optional password for basic authentication
+ *
+ * @var string|false
+ */
+ protected $password;
+
+ /**
+ * The optional list of params
+ *
+ * @var Params
+ */
+ protected $params;
+
+ /**
+ * The optional path
+ *
+ * @var Path
+ */
+ protected $path;
+
+ /**
+ * The optional port number
+ *
+ * @var int|false
+ */
+ protected $port;
+
+ /**
+ * All original properties
+ *
+ * @var array
+ */
+ protected $props;
+
+ /**
+ * The optional query string without leading ?
+ *
+ * @var Query
+ */
+ protected $query;
+
+ /**
+ * https or http
+ *
+ * @var string
+ */
+ protected $scheme = 'http';
+
+ /**
+ * @var bool
+ */
+ protected $slash = false;
+
+ /**
+ * The optional username for basic authentication
+ *
+ * @var string|false
+ */
+ protected $username;
+
+ /**
+ * Magic caller to access all properties
+ *
+ * @param string $property
+ * @param array $arguments
+ * @return mixed
+ */
+ public function __call(string $property, array $arguments = [])
+ {
+ return $this->$property ?? null;
+ }
+
+ /**
+ * Make sure that cloning also clones
+ * the path and query objects
+ *
+ * @return void
+ */
+ public function __clone()
+ {
+ $this->path = clone $this->path;
+ $this->query = clone $this->query;
+ $this->params = clone $this->params;
+ }
+
+ /**
+ * Creates a new URI object
+ *
+ * @param array $props
+ * @param array $inject
+ */
+ public function __construct($props = [], array $inject = [])
+ {
+ if (is_string($props) === true) {
+ $props = parse_url($props);
+ $props['username'] = $props['user'] ?? null;
+ $props['password'] = $props['pass'] ?? null;
+
+ $props = array_merge($props, $inject);
+ }
+
+ // parse the path and extract params
+ if (empty($props['path']) === false) {
+ $extract = Params::extract($props['path']);
+ $props['params'] = $props['params'] ?? $extract['params'];
+ $props['path'] = $extract['path'];
+ $props['slash'] = $props['slash'] ?? $extract['slash'];
+ }
+
+ $this->setProperties($this->props = $props);
+ }
+
+ /**
+ * Magic getter
+ *
+ * @param string $property
+ * @return mixed
+ */
+ public function __get(string $property)
+ {
+ return $this->$property ?? null;
+ }
+
+ /**
+ * Magic setter
+ *
+ * @param string $property
+ * @param mixed $value
+ */
+ public function __set(string $property, $value)
+ {
+ if (method_exists($this, 'set' . $property) === true) {
+ $this->{'set' . $property}($value);
+ }
+ }
+
+ /**
+ * Converts the URL object to string
+ *
+ * @return string
+ */
+ public function __toString(): string
+ {
+ try {
+ return $this->toString();
+ } catch (Throwable $e) {
+ return '';
+ }
+ }
+
+ /**
+ * Returns the auth details (username:password)
+ *
+ * @return string|null
+ */
+ public function auth(): ?string
+ {
+ $auth = trim($this->username . ':' . $this->password);
+ return $auth !== ':' ? $auth : null;
+ }
+
+ /**
+ * Returns the base url (scheme + host)
+ * without trailing slash
+ *
+ * @return string|null
+ */
+ public function base(): ?string
+ {
+ if ($domain = $this->domain()) {
+ return $this->scheme ? $this->scheme . '://' . $domain : $domain;
+ }
+
+ return null;
+ }
+
+ /**
+ * Clones the Uri object and applies optional
+ * new props.
+ *
+ * @param array $props
+ * @return self
+ */
+ public function clone(array $props = [])
+ {
+ $clone = clone $this;
+
+ foreach ($props as $key => $value) {
+ $clone->__set($key, $value);
+ }
+
+ return $clone;
+ }
+
+ /**
+ * @param array $props
+ * @param bool $forwarded
+ * @return self
+ */
+ public static function current(array $props = [], bool $forwarded = false)
+ {
+ if (static::$current !== null) {
+ return static::$current;
+ }
+
+ $uri = Server::get('REQUEST_URI');
+ $uri = preg_replace('!^(http|https)\:\/\/' . Server::get('HTTP_HOST') . '!', '', $uri);
+ $uri = parse_url('http://getkirby.com' . $uri);
+
+ $url = new static(array_merge([
+ 'scheme' => Server::https() === true ? 'https' : 'http',
+ 'host' => Server::host($forwarded),
+ 'port' => Server::port($forwarded),
+ 'path' => $uri['path'] ?? null,
+ 'query' => $uri['query'] ?? null,
+ ], $props));
+
+ return $url;
+ }
+
+ /**
+ * Returns the domain without scheme, path or query
+ *
+ * @return string|null
+ */
+ public function domain(): ?string
+ {
+ if (empty($this->host) === true || $this->host === '/') {
+ return null;
+ }
+
+ $auth = $this->auth();
+ $domain = '';
+
+ if ($auth !== null) {
+ $domain .= $auth . '@';
+ }
+
+ $domain .= $this->host;
+
+ if ($this->port !== null && in_array($this->port, [80, 443]) === false) {
+ $domain .= ':' . $this->port;
+ }
+
+ return $domain;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasFragment(): bool
+ {
+ return empty($this->fragment) === false;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasPath(): bool
+ {
+ return $this->path()->isNotEmpty();
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasQuery(): bool
+ {
+ return $this->query()->isNotEmpty();
+ }
+
+ /**
+ * Tries to convert the internationalized host
+ * name to the human-readable UTF8 representation
+ *
+ * @return self
+ */
+ public function idn()
+ {
+ if (empty($this->host) === false) {
+ $this->setHost(Idn::decode($this->host));
+ }
+ return $this;
+ }
+
+ /**
+ * Creates an Uri object for the URL to the index.php
+ * or any other executed script.
+ *
+ * @param array $props
+ * @param bool $forwarded
+ * @return string
+ */
+ public static function index(array $props = [], bool $forwarded = false)
+ {
+ if (Server::cli() === true) {
+ $path = null;
+ } else {
+ $path = Server::get('SCRIPT_NAME');
+ // replace Windows backslashes
+ $path = str_replace('\\', '/', $path);
+ // remove the script
+ $path = dirname($path);
+ // replace those fucking backslashes again
+ $path = str_replace('\\', '/', $path);
+ // remove the leading and trailing slashes
+ $path = trim($path, '/');
+ }
+
+ if ($path === '.') {
+ $path = null;
+ }
+
+ return static::current(array_merge($props, [
+ 'path' => $path,
+ 'query' => null,
+ 'fragment' => null,
+ ]), $forwarded);
+ }
+
+
+ /**
+ * Checks if the host exists
+ *
+ * @return bool
+ */
+ public function isAbsolute(): bool
+ {
+ return empty($this->host) === false;
+ }
+
+ /**
+ * @param string|null $fragment
+ * @return self
+ */
+ public function setFragment(string $fragment = null)
+ {
+ $this->fragment = $fragment ? ltrim($fragment, '#') : null;
+ return $this;
+ }
+
+ /**
+ * @param string $host
+ * @return self
+ */
+ public function setHost(string $host = null)
+ {
+ $this->host = $host;
+ return $this;
+ }
+
+ /**
+ * @param \Kirby\Http\Params|string|array|null $params
+ * @return self
+ */
+ public function setParams($params = null)
+ {
+ $this->params = is_a($params, 'Kirby\Http\Params') === true ? $params : new Params($params);
+ return $this;
+ }
+
+ /**
+ * @param string|null $password
+ * @return self
+ */
+ public function setPassword(string $password = null)
+ {
+ $this->password = $password;
+ return $this;
+ }
+
+ /**
+ * @param \Kirby\Http\Path|string|array|null $path
+ * @return self
+ */
+ public function setPath($path = null)
+ {
+ $this->path = is_a($path, 'Kirby\Http\Path') === true ? $path : new Path($path);
+ return $this;
+ }
+
+ /**
+ * @param int|null $port
+ * @return self
+ */
+ public function setPort(int $port = null)
+ {
+ if ($port === 0) {
+ $port = null;
+ }
+
+ if ($port !== null) {
+ if ($port < 1 || $port > 65535) {
+ throw new InvalidArgumentException('Invalid port format: ' . $port);
+ }
+ }
+
+ $this->port = $port;
+ return $this;
+ }
+
+ /**
+ * @param \Kirby\Http\Query|string|array|null $query
+ * @return self
+ */
+ public function setQuery($query = null)
+ {
+ $this->query = is_a($query, 'Kirby\Http\Query') === true ? $query : new Query($query);
+ return $this;
+ }
+
+ /**
+ * @param string $scheme
+ * @return self
+ */
+ public function setScheme(string $scheme = null)
+ {
+ if ($scheme !== null && in_array($scheme, ['http', 'https', 'ftp']) === false) {
+ throw new InvalidArgumentException('Invalid URL scheme: ' . $scheme);
+ }
+
+ $this->scheme = $scheme;
+ return $this;
+ }
+
+ /**
+ * Set if a trailing slash should be added to
+ * the path when the URI is being built
+ *
+ * @param bool $slash
+ * @return self
+ */
+ public function setSlash(bool $slash = false)
+ {
+ $this->slash = $slash;
+ return $this;
+ }
+
+ /**
+ * @param string|null $username
+ * @return self
+ */
+ public function setUsername(string $username = null)
+ {
+ $this->username = $username;
+ return $this;
+ }
+
+ /**
+ * Converts the Url object to an array
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ $array = [];
+
+ foreach ($this->propertyData as $key => $value) {
+ $value = $this->$key;
+
+ if (is_object($value) === true) {
+ $value = $value->toArray();
+ }
+
+ $array[$key] = $value;
+ }
+
+ return $array;
+ }
+
+ public function toJson(...$arguments): string
+ {
+ return json_encode($this->toArray(), ...$arguments);
+ }
+
+ /**
+ * Returns the full URL as string
+ *
+ * @return string
+ */
+ public function toString(): string
+ {
+ $url = $this->base();
+ $slash = true;
+
+ if (empty($url) === true) {
+ $url = '/';
+ $slash = false;
+ }
+
+ $path = $this->path->toString($slash) . $this->params->toString(true);
+
+ if ($this->slash && $slash === true) {
+ $path .= '/';
+ }
+
+ $url .= $path;
+ $url .= $this->query->toString(true);
+
+ if (empty($this->fragment) === false) {
+ $url .= '#' . $this->fragment;
+ }
+
+ return $url;
+ }
+
+ /**
+ * Tries to convert a URL with an internationalized host
+ * name to the machine-readable Punycode representation
+ *
+ * @return self
+ */
+ public function unIdn()
+ {
+ if (empty($this->host) === false) {
+ $this->setHost(Idn::encode($this->host));
+ }
+ return $this;
+ }
+}
diff --git a/kirby/src/Http/Url.php b/kirby/src/Http/Url.php
new file mode 100755
index 0000000..da21800
--- /dev/null
+++ b/kirby/src/Http/Url.php
@@ -0,0 +1,287 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Url
+{
+ /**
+ * The base Url to build absolute Urls from
+ *
+ * @var string
+ */
+ public static $home = '/';
+
+ /**
+ * The current Uri object
+ *
+ * @var Uri
+ */
+ public static $current = null;
+
+ /**
+ * Facade for all Uri object methods
+ *
+ * @param string $method
+ * @param array $arguments
+ * @return mixed
+ */
+ public static function __callStatic(string $method, $arguments)
+ {
+ return (new Uri($arguments[0] ?? static::current()))->$method(...array_slice($arguments, 1));
+ }
+
+ /**
+ * Url Builder
+ * Actually just a factory for `new Uri($parts)`
+ *
+ * @param array $parts
+ * @param string|null $url
+ * @return string
+ */
+ public static function build(array $parts = [], string $url = null): string
+ {
+ return (string)(new Uri($url ?? static::current()))->clone($parts);
+ }
+
+ /**
+ * Returns the current url with all bells and whistles
+ *
+ * @return string
+ */
+ public static function current(): string
+ {
+ return static::$current = static::$current ?? static::toObject()->toString();
+ }
+
+ /**
+ * Returns the url for the current directory
+ *
+ * @return string
+ */
+ public static function currentDir(): string
+ {
+ return dirname(static::current());
+ }
+
+ /**
+ * Tries to fix a broken url without protocol
+ *
+ * @param string $url
+ * @return string
+ */
+ public static function fix(string $url = null): string
+ {
+ // make sure to not touch absolute urls
+ return (!preg_match('!^(https|http|ftp)\:\/\/!i', $url)) ? 'http://' . $url : $url;
+ }
+
+ /**
+ * Returns the home url if defined
+ *
+ * @return string
+ */
+ public static function home(): string
+ {
+ return static::$home;
+ }
+
+ /**
+ * Returns the url to the executed script
+ *
+ * @param array $props
+ * @param bool $forwarded
+ * @return string
+ */
+ public static function index(array $props = [], bool $forwarded = false): string
+ {
+ return Uri::index($props, $forwarded)->toString();
+ }
+
+ /**
+ * Checks if an URL is absolute
+ *
+ * @param string $url
+ * @return bool
+ */
+ public static function isAbsolute(string $url = null): bool
+ {
+ // matches the following groups of URLs:
+ // //example.com/uri
+ // http://example.com/uri, https://example.com/uri, ftp://example.com/uri
+ // mailto:example@example.com
+ return preg_match('!^(//|[a-z0-9+-.]+://|mailto:|tel:)!i', $url) === 1;
+ }
+
+ /**
+ * Convert a relative path into an absolute URL
+ *
+ * @param string $path
+ * @param string $home
+ * @return string
+ */
+ public static function makeAbsolute(string $path = null, string $home = null): string
+ {
+ if ($path === '' || $path === '/' || $path === null) {
+ return $home ?? static::home();
+ }
+
+ if (substr($path, 0, 1) === '#') {
+ return $path;
+ }
+
+ if (static::isAbsolute($path)) {
+ return $path;
+ }
+
+ // build the full url
+ $path = ltrim($path, '/');
+ $home = $home ?? static::home();
+
+ if (empty($path) === true) {
+ return $home;
+ }
+
+ return $home === '/' ? '/' . $path : $home . '/' . $path;
+ }
+
+ /**
+ * Returns the path for the given url
+ *
+ * @param string|array|null $url
+ * @param bool $leadingSlash
+ * @param bool $trailingSlash
+ * @return xtring
+ */
+ public static function path($url = null, bool $leadingSlash = false, bool $trailingSlash = false): string
+ {
+ return Url::toObject($url)->path()->toString($leadingSlash, $trailingSlash);
+ }
+
+ /**
+ * Returns the query for the given url
+ *
+ * @param string|array|null $url
+ * @return string
+ */
+ public static function query($url = null): string
+ {
+ return Url::toObject($url)->query()->toString();
+ }
+
+ /**
+ * Return the last url the user has been on if detectable
+ *
+ * @return string
+ */
+ public static function last(): string
+ {
+ return $_SERVER['HTTP_REFERER'] ?? '';
+ }
+
+ /**
+ * Shortens the Url by removing all unnecessary parts
+ *
+ * @param string $url
+ * @param int $length
+ * @param bool $base
+ * @param string $rep
+ * @return string
+ */
+ public static function short($url = null, int $length = 0, bool $base = false, string $rep = '…'): string
+ {
+ $uri = static::toObject($url);
+
+ $uri->fragment = null;
+ $uri->query = null;
+ $uri->password = null;
+ $uri->port = null;
+ $uri->scheme = null;
+ $uri->username = null;
+
+ // remove the trailing slash from the path
+ $uri->slash = false;
+
+ $url = $base ? $uri->base() : $uri->toString();
+ $url = str_replace('www.', '', $url);
+
+ return Str::short($url, $length, $rep);
+ }
+
+ /**
+ * Removes the path from the Url
+ *
+ * @param string $url
+ * @return string
+ */
+ public static function stripPath($url = null): string
+ {
+ return static::toObject($url)->setPath(null)->toString();
+ }
+
+ /**
+ * Removes the query string from the Url
+ *
+ * @param string $url
+ * @return string
+ */
+ public static function stripQuery($url = null): string
+ {
+ return static::toObject($url)->setQuery(null)->toString();
+ }
+
+ /**
+ * Removes the fragment (hash) from the Url
+ *
+ * @param string $url
+ * @return string
+ */
+ public static function stripFragment($url = null): string
+ {
+ return static::toObject($url)->setFragment(null)->toString();
+ }
+
+ /**
+ * Smart resolver for internal and external urls
+ *
+ * @param string $path
+ * @param mixed $options
+ * @return string
+ */
+ public static function to(string $path = null, $options = null): string
+ {
+ // keep relative urls
+ if (substr($path, 0, 2) === './' || substr($path, 0, 3) === '../') {
+ return $path;
+ }
+
+ $url = static::makeAbsolute($path);
+
+ if ($options === null) {
+ return $url;
+ }
+
+ return (new Uri($url, $options))->toString();
+ }
+
+ /**
+ * Converts the Url to a Uri object
+ *
+ * @param string $url
+ * @return \Kirby\Http\Uri
+ */
+ public static function toObject($url = null)
+ {
+ return $url === null ? Uri::current() : new Uri($url);
+ }
+}
diff --git a/kirby/src/Http/Visitor.php b/kirby/src/Http/Visitor.php
new file mode 100755
index 0000000..4ae7102
--- /dev/null
+++ b/kirby/src/Http/Visitor.php
@@ -0,0 +1,252 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Visitor
+{
+ /**
+ * IP address
+ * @var string|null
+ */
+ protected $ip;
+
+ /**
+ * user agent
+ * @var string|null
+ */
+ protected $userAgent;
+
+ /**
+ * accepted language
+ * @var string|null
+ */
+ protected $acceptedLanguage;
+
+ /**
+ * accepted mime type
+ * @var string|null
+ */
+ protected $acceptedMimeType;
+
+ /**
+ * Creates a new visitor object.
+ * Optional arguments can be passed to
+ * modify the information about the visitor.
+ *
+ * By default everything is pulled from $_SERVER
+ *
+ * @param array $arguments
+ */
+ public function __construct(array $arguments = [])
+ {
+ $this->ip($arguments['ip'] ?? getenv('REMOTE_ADDR'));
+ $this->userAgent($arguments['userAgent'] ?? $_SERVER['HTTP_USER_AGENT'] ?? '');
+ $this->acceptedLanguage($arguments['acceptedLanguage'] ?? $_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? '');
+ $this->acceptedMimeType($arguments['acceptedMimeType'] ?? $_SERVER['HTTP_ACCEPT'] ?? '');
+ }
+
+ /**
+ * Sets the accepted language if
+ * provided or returns the user's
+ * accepted language otherwise
+ *
+ * @param string|null $acceptedLanguage
+ * @return \Kirby\Toolkit\Obj|\Kirby\Http\Visitor|null
+ */
+ public function acceptedLanguage(string $acceptedLanguage = null)
+ {
+ if ($acceptedLanguage === null) {
+ return $this->acceptedLanguages()->first();
+ }
+
+ $this->acceptedLanguage = $acceptedLanguage;
+ return $this;
+ }
+
+ /**
+ * Returns an array of all accepted languages
+ * including their quality and locale
+ *
+ * @return \Kirby\Toolkit\Collection
+ */
+ public function acceptedLanguages()
+ {
+ $accepted = Str::accepted($this->acceptedLanguage);
+ $languages = [];
+
+ foreach ($accepted as $language) {
+ $value = $language['value'];
+ $parts = Str::split($value, '-');
+ $code = isset($parts[0]) ? Str::lower($parts[0]) : null;
+ $region = isset($parts[1]) ? Str::upper($parts[1]) : null;
+ $locale = $region ? $code . '_' . $region : $code;
+
+ $languages[$locale] = new Obj([
+ 'code' => $code,
+ 'locale' => $locale,
+ 'original' => $value,
+ 'quality' => $language['quality'],
+ 'region' => $region,
+ ]);
+ }
+
+ return new Collection($languages);
+ }
+
+ /**
+ * Checks if the user accepts the given language
+ *
+ * @param string $code
+ * @return bool
+ */
+ public function acceptsLanguage(string $code): bool
+ {
+ $mode = Str::contains($code, '_') === true ? 'locale' : 'code';
+
+ foreach ($this->acceptedLanguages() as $language) {
+ if ($language->$mode() === $code) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Sets the accepted mime type if
+ * provided or returns the user's
+ * accepted mime type otherwise
+ *
+ * @param string|null $acceptedMimeType
+ * @return \Kirby\Toolkit\Obj|\Kirby\Http\Visitor
+ */
+ public function acceptedMimeType(string $acceptedMimeType = null)
+ {
+ if ($acceptedMimeType === null) {
+ return $this->acceptedMimeTypes()->first();
+ }
+
+ $this->acceptedMimeType = $acceptedMimeType;
+ return $this;
+ }
+
+ /**
+ * Returns a collection of all accepted mime types
+ *
+ * @return \Kirby\Toolkit\Collection
+ */
+ public function acceptedMimeTypes()
+ {
+ $accepted = Str::accepted($this->acceptedMimeType);
+ $mimes = [];
+
+ foreach ($accepted as $mime) {
+ $mimes[$mime['value']] = new Obj([
+ 'type' => $mime['value'],
+ 'quality' => $mime['quality'],
+ ]);
+ }
+
+ return new Collection($mimes);
+ }
+
+ /**
+ * Checks if the user accepts the given mime type
+ *
+ * @param string $mimeType
+ * @return bool
+ */
+ public function acceptsMimeType(string $mimeType): bool
+ {
+ return Mime::isAccepted($mimeType, $this->acceptedMimeType);
+ }
+
+ /**
+ * Returns the MIME type from the provided list that
+ * is most accepted (= preferred) by the visitor
+ * @since 3.3.0
+ *
+ * @param string ...$mimeTypes MIME types to query for
+ * @return string|null Preferred MIME type
+ */
+ public function preferredMimeType(string ...$mimeTypes): ?string
+ {
+ foreach ($this->acceptedMimeTypes() as $acceptedMime) {
+ // look for direct matches
+ if (in_array($acceptedMime->type(), $mimeTypes)) {
+ return $acceptedMime->type();
+ }
+
+ // test each option against wildcard `Accept` values
+ foreach ($mimeTypes as $expectedMime) {
+ if (Mime::matches($expectedMime, $acceptedMime->type()) === true) {
+ return $expectedMime;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns true if the visitor prefers a JSON response over
+ * an HTML response based on the `Accept` request header
+ * @since 3.3.0
+ *
+ * @return bool
+ */
+ public function prefersJson(): bool
+ {
+ return $this->preferredMimeType('application/json', 'text/html') === 'application/json';
+ }
+
+ /**
+ * Sets the ip address if provided
+ * or returns the ip of the current
+ * visitor otherwise
+ *
+ * @param string|null $ip
+ * @return string|Visitor|null
+ */
+ public function ip(string $ip = null)
+ {
+ if ($ip === null) {
+ return $this->ip;
+ }
+ $this->ip = $ip;
+ return $this;
+ }
+
+ /**
+ * Sets the user agent if provided
+ * or returns the user agent string of
+ * the current visitor otherwise
+ *
+ * @param string|null $userAgent
+ * @return string|Visitor|null
+ */
+ public function userAgent(string $userAgent = null)
+ {
+ if ($userAgent === null) {
+ return $this->userAgent;
+ }
+ $this->userAgent = $userAgent;
+ return $this;
+ }
+}
diff --git a/kirby/src/Image/Camera.php b/kirby/src/Image/Camera.php
new file mode 100755
index 0000000..a59e4d9
--- /dev/null
+++ b/kirby/src/Image/Camera.php
@@ -0,0 +1,93 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Camera
+{
+ /**
+ * Make exif data
+ *
+ * @var string|null
+ */
+ protected $make;
+
+ /**
+ * Model exif data
+ *
+ * @var string|null
+ */
+ protected $model;
+
+ /**
+ * Constructor
+ *
+ * @param array $exif
+ */
+ public function __construct(array $exif)
+ {
+ $this->make = @$exif['Make'];
+ $this->model = @$exif['Model'];
+ }
+
+ /**
+ * Returns the make of the camera
+ *
+ * @return string
+ */
+ public function make(): ?string
+ {
+ return $this->make;
+ }
+
+ /**
+ * Returns the camera model
+ *
+ * @return string
+ */
+ public function model(): ?string
+ {
+ return $this->model;
+ }
+
+ /**
+ * Converts the object into a nicely readable array
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return [
+ 'make' => $this->make,
+ 'model' => $this->model
+ ];
+ }
+
+ /**
+ * Returns the full make + model name
+ *
+ * @return string
+ */
+ public function __toString(): string
+ {
+ return trim($this->make . ' ' . $this->model);
+ }
+
+ /**
+ * Improved `var_dump` output
+ *
+ * @return array
+ */
+ public function __debugInfo(): array
+ {
+ return $this->toArray();
+ }
+}
diff --git a/kirby/src/Image/Darkroom.php b/kirby/src/Image/Darkroom.php
new file mode 100755
index 0000000..0c4cafb
--- /dev/null
+++ b/kirby/src/Image/Darkroom.php
@@ -0,0 +1,103 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Darkroom
+{
+ public static $types = [
+ 'gd' => 'Kirby\Image\Darkroom\GdLib',
+ 'im' => 'Kirby\Image\Darkroom\ImageMagick'
+ ];
+
+ protected $settings = [];
+
+ public function __construct(array $settings = [])
+ {
+ $this->settings = array_merge($this->defaults(), $settings);
+ }
+
+ public static function factory(string $type, array $settings = [])
+ {
+ if (isset(static::$types[$type]) === false) {
+ throw new Exception('Invalid Darkroom type');
+ }
+
+ $class = static::$types[$type];
+ return new $class($settings);
+ }
+
+ protected function defaults(): array
+ {
+ return [
+ 'autoOrient' => true,
+ 'crop' => false,
+ 'blur' => false,
+ 'grayscale' => false,
+ 'height' => null,
+ 'quality' => 90,
+ 'width' => null,
+ ];
+ }
+
+ protected function options(array $options = []): array
+ {
+ $options = array_merge($this->settings, $options);
+
+ // normalize the crop option
+ if ($options['crop'] === true) {
+ $options['crop'] = 'center';
+ }
+
+ // normalize the blur option
+ if ($options['blur'] === true) {
+ $options['blur'] = 10;
+ }
+
+ // normalize the greyscale option
+ if (isset($options['greyscale']) === true) {
+ $options['grayscale'] = $options['greyscale'];
+ unset($options['greyscale']);
+ }
+
+ // normalize the bw option
+ if (isset($options['bw']) === true) {
+ $options['grayscale'] = $options['bw'];
+ unset($options['bw']);
+ }
+
+ if ($options['quality'] === null) {
+ $options['quality'] = $this->settings['quality'];
+ }
+
+ return $options;
+ }
+
+ public function preprocess(string $file, array $options = [])
+ {
+ $options = $this->options($options);
+ $image = new Image($file);
+ $dimensions = $image->dimensions()->thumb($options);
+
+ $options['width'] = $dimensions->width();
+ $options['height'] = $dimensions->height();
+
+ return $options;
+ }
+
+ public function process(string $file, array $options = []): array
+ {
+ return $this->preprocess($file, $options);
+ }
+}
diff --git a/kirby/src/Image/Darkroom/GdLib.php b/kirby/src/Image/Darkroom/GdLib.php
new file mode 100755
index 0000000..3d9b08c
--- /dev/null
+++ b/kirby/src/Image/Darkroom/GdLib.php
@@ -0,0 +1,73 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class GdLib extends Darkroom
+{
+ public function process(string $file, array $options = []): array
+ {
+ $options = $this->preprocess($file, $options);
+
+ $image = new SimpleImage();
+ $image->fromFile($file);
+
+ $image = $this->resize($image, $options);
+ $image = $this->autoOrient($image, $options);
+ $image = $this->blur($image, $options);
+ $image = $this->grayscale($image, $options);
+
+ $image->toFile($file, null, $options['quality']);
+
+ return $options;
+ }
+
+ protected function autoOrient(SimpleImage $image, $options)
+ {
+ if ($options['autoOrient'] === false) {
+ return $image;
+ }
+
+ return $image->autoOrient();
+ }
+
+ protected function resize(SimpleImage $image, array $options)
+ {
+ if ($options['crop'] === false) {
+ return $image->resize($options['width'], $options['height']);
+ }
+
+ return $image->thumbnail($options['width'], $options['height'] ?? $options['width'], $options['crop']);
+ }
+
+ protected function blur(SimpleImage $image, array $options)
+ {
+ if ($options['blur'] === false) {
+ return $image;
+ }
+
+ return $image->blur('gaussian', (int)$options['blur']);
+ }
+
+ protected function grayscale(SimpleImage $image, array $options)
+ {
+ if ($options['grayscale'] === false) {
+ return $image;
+ }
+
+ return $image->desaturate();
+ }
+}
diff --git a/kirby/src/Image/Darkroom/ImageMagick.php b/kirby/src/Image/Darkroom/ImageMagick.php
new file mode 100755
index 0000000..b997552
--- /dev/null
+++ b/kirby/src/Image/Darkroom/ImageMagick.php
@@ -0,0 +1,140 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class ImageMagick extends Darkroom
+{
+ protected function autoOrient(string $file, array $options)
+ {
+ if ($options['autoOrient'] === true) {
+ return '-auto-orient';
+ }
+ }
+
+ protected function blur(string $file, array $options)
+ {
+ if ($options['blur'] !== false) {
+ return '-blur 0x' . $options['blur'];
+ }
+ }
+
+ protected function coalesce(string $file, array $options)
+ {
+ if (F::extension($file) === 'gif') {
+ return '-coalesce';
+ }
+ }
+
+ protected function convert(string $file, array $options): string
+ {
+ return sprintf($options['bin'] . ' "%s"', $file);
+ }
+
+ protected function defaults(): array
+ {
+ return parent::defaults() + [
+ 'bin' => 'convert',
+ 'interlace' => false,
+ ];
+ }
+
+ protected function grayscale(string $file, array $options)
+ {
+ if ($options['grayscale'] === true) {
+ return '-colorspace gray';
+ }
+ }
+
+ protected function interlace(string $file, array $options)
+ {
+ if ($options['interlace'] === true) {
+ return '-interlace line';
+ }
+ }
+
+ public function process(string $file, array $options = []): array
+ {
+ $options = $this->preprocess($file, $options);
+ $command = [];
+
+ $command[] = $this->convert($file, $options);
+ $command[] = $this->strip($file, $options);
+ $command[] = $this->interlace($file, $options);
+ $command[] = $this->coalesce($file, $options);
+ $command[] = $this->grayscale($file, $options);
+ $command[] = $this->autoOrient($file, $options);
+ $command[] = $this->resize($file, $options);
+ $command[] = $this->quality($file, $options);
+ $command[] = $this->blur($file, $options);
+ $command[] = $this->save($file, $options);
+
+ // remove all null values and join the parts
+ $command = implode(' ', array_filter($command));
+
+ // try to execute the command
+ exec($command, $output, $return);
+
+ // log broken commands
+ if ($return !== 0) {
+ throw new Exception('The imagemagick convert command could not be executed: ' . $command);
+ }
+
+ return $options;
+ }
+
+ protected function quality(string $file, array $options): string
+ {
+ return '-quality ' . $options['quality'];
+ }
+
+ protected function resize(string $file, array $options): string
+ {
+ // simple resize
+ if ($options['crop'] === false) {
+ return sprintf('-resize %sx%s!', $options['width'], $options['height']);
+ }
+
+ $gravities = [
+ 'top left' => 'NorthWest',
+ 'top' => 'North',
+ 'top right' => 'NorthEast',
+ 'left' => 'West',
+ 'center' => 'Center',
+ 'right' => 'East',
+ 'bottom left' => 'SouthWest',
+ 'bottom' => 'South',
+ 'bottom right' => 'SouthEast'
+ ];
+
+ // translate the gravity option into something imagemagick understands
+ $gravity = $gravities[$options['crop']] ?? 'Center';
+
+ $command = sprintf('-resize %sx%s^', $options['width'], $options['height']);
+ $command .= sprintf(' -gravity %s -crop %sx%s+0+0', $gravity, $options['width'], $options['height']);
+
+ return $command;
+ }
+
+ protected function save(string $file, array $options): string
+ {
+ return sprintf('-limit thread 1 "%s"', $file);
+ }
+
+ protected function strip(string $file, array $options): string
+ {
+ return '-strip';
+ }
+}
diff --git a/kirby/src/Image/Dimensions.php b/kirby/src/Image/Dimensions.php
new file mode 100755
index 0000000..ed20b88
--- /dev/null
+++ b/kirby/src/Image/Dimensions.php
@@ -0,0 +1,430 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Dimensions
+{
+ /**
+ * the height of the parent object
+ *
+ * @var int
+ */
+ public $height = 0;
+
+ /**
+ * the width of the parent object
+ *
+ * @var int
+ */
+ public $width = 0;
+
+ /**
+ * Constructor
+ *
+ * @param int $width
+ * @param int $height
+ */
+ public function __construct(int $width, int $height)
+ {
+ $this->width = $width;
+ $this->height = $height;
+ }
+
+ /**
+ * Improved `var_dump` output
+ *
+ * @return array
+ */
+ public function __debugInfo(): array
+ {
+ return $this->toArray();
+ }
+
+ /**
+ * Echos the dimensions as width × height
+ *
+ * @return string
+ */
+ public function __toString(): string
+ {
+ return $this->width . ' × ' . $this->height;
+ }
+
+ /**
+ * Crops the dimensions by width and height
+ *
+ * @param int $width
+ * @param int $height
+ * @return self
+ */
+ public function crop(int $width, int $height = null)
+ {
+ $this->width = $width;
+ $this->height = $width;
+
+ if ($height !== 0 && $height !== null) {
+ $this->height = $height;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Returns the height
+ *
+ * @return int
+ */
+ public function height()
+ {
+ return $this->height;
+ }
+
+ /**
+ * Recalculates the width and height to fit into the given box.
+ *
+ *
+ *
+ * $dimensions = new Dimensions(1200, 768);
+ * $dimensions->fit(500);
+ *
+ * echo $dimensions->width();
+ * // output: 500
+ *
+ * echo $dimensions->height();
+ * // output: 320
+ *
+ *
+ *
+ * @param int $box the max width and/or height
+ * @param bool $force If true, the dimensions will be
+ * upscaled to fit the box if smaller
+ * @return self object with recalculated dimensions
+ */
+ public function fit(int $box, bool $force = false)
+ {
+ if ($this->width == 0 || $this->height == 0) {
+ $this->width = $box;
+ $this->height = $box;
+ return $this;
+ }
+
+ $ratio = $this->ratio();
+
+ if ($this->width > $this->height) {
+ // wider than tall
+ if ($this->width > $box || $force === true) {
+ $this->width = $box;
+ }
+ $this->height = (int)round($this->width / $ratio);
+ } elseif ($this->height > $this->width) {
+ // taller than wide
+ if ($this->height > $box || $force === true) {
+ $this->height = $box;
+ }
+ $this->width = (int)round($this->height * $ratio);
+ } elseif ($this->width > $box) {
+ // width = height but bigger than box
+ $this->width = $box;
+ $this->height = $box;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Recalculates the width and height to fit the given height
+ *
+ *
+ *
+ * $dimensions = new Dimensions(1200, 768);
+ * $dimensions->fitHeight(500);
+ *
+ * echo $dimensions->width();
+ * // output: 781
+ *
+ * echo $dimensions->height();
+ * // output: 500
+ *
+ *
+ *
+ * @param int $fit the max height
+ * @param bool $force If true, the dimensions will be
+ * upscaled to fit the box if smaller
+ * @return self object with recalculated dimensions
+ */
+ public function fitHeight(int $fit = null, bool $force = false)
+ {
+ return $this->fitSize('height', $fit, $force);
+ }
+
+ /**
+ * Helper for fitWidth and fitHeight methods
+ *
+ * @param string $ref reference (width or height)
+ * @param int $fit the max width
+ * @param bool $force If true, the dimensions will be
+ * upscaled to fit the box if smaller
+ * @return self object with recalculated dimensions
+ */
+ protected function fitSize(string $ref, int $fit = null, bool $force = false)
+ {
+ if ($fit === 0 || $fit === null) {
+ return $this;
+ }
+
+ if ($this->$ref <= $fit && !$force) {
+ return $this;
+ }
+
+ $ratio = $this->ratio();
+ $mode = $ref === 'width';
+ $this->width = $mode ? $fit : (int)round($fit * $ratio);
+ $this->height = !$mode ? $fit : (int)round($fit / $ratio);
+
+ return $this;
+ }
+
+ /**
+ * Recalculates the width and height to fit the given width
+ *
+ *
+ *
+ * $dimensions = new Dimensions(1200, 768);
+ * $dimensions->fitWidth(500);
+ *
+ * echo $dimensions->width();
+ * // output: 500
+ *
+ * echo $dimensions->height();
+ * // output: 320
+ *
+ *
+ *
+ * @param int $fit the max width
+ * @param bool $force If true, the dimensions will be
+ * upscaled to fit the box if smaller
+ * @return self object with recalculated dimensions
+ */
+ public function fitWidth(int $fit = null, bool $force = false)
+ {
+ return $this->fitSize('width', $fit, $force);
+ }
+
+ /**
+ * Recalculates the dimensions by the width and height
+ *
+ * @param int $width the max height
+ * @param int $height the max width
+ * @param bool $force
+ * @return self
+ */
+ public function fitWidthAndHeight(int $width = null, int $height = null, bool $force = false)
+ {
+ if ($this->width > $this->height) {
+ $this->fitWidth($width, $force);
+
+ // do another check for the max height
+ if ($this->height > $height) {
+ $this->fitHeight($height);
+ }
+ } else {
+ $this->fitHeight($height, $force);
+
+ // do another check for the max width
+ if ($this->width > $width) {
+ $this->fitWidth($width);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Detect the dimensions for an image file
+ *
+ * @param string $root
+ * @return self
+ */
+ public static function forImage(string $root)
+ {
+ if (file_exists($root) === false) {
+ return new static(0, 0);
+ }
+
+ $size = getimagesize($root);
+ return new static($size[0] ?? 0, $size[1] ?? 1);
+ }
+
+ /**
+ * Detect the dimensions for a svg file
+ *
+ * @param string $root
+ * @return self
+ */
+ public static function forSvg(string $root)
+ {
+ // avoid xml errors
+ libxml_use_internal_errors(true);
+
+ $content = file_get_contents($root);
+ $height = 0;
+ $width = 0;
+ $xml = simplexml_load_string($content);
+
+ if ($xml !== false) {
+ $attr = $xml->attributes();
+ $width = (float)($attr->width);
+ $height = (float)($attr->height);
+ if (($width === 0.0 || $height === 0.0) && empty($attr->viewBox) === false) {
+ $box = explode(' ', $attr->viewBox);
+ $width = (float)($box[2] ?? 0);
+ $height = (float)($box[3] ?? 0);
+ }
+ }
+
+ return new static($width, $height);
+ }
+
+ /**
+ * Checks if the dimensions are landscape
+ *
+ * @return bool
+ */
+ public function landscape(): bool
+ {
+ return $this->width > $this->height;
+ }
+
+ /**
+ * Returns a string representation of the orientation
+ *
+ * @return string|false
+ */
+ public function orientation()
+ {
+ if (!$this->ratio()) {
+ return false;
+ }
+
+ if ($this->portrait()) {
+ return 'portrait';
+ }
+
+ if ($this->landscape()) {
+ return 'landscape';
+ }
+
+ return 'square';
+ }
+
+ /**
+ * Checks if the dimensions are portrait
+ *
+ * @return bool
+ */
+ public function portrait(): bool
+ {
+ return $this->height > $this->width;
+ }
+
+ /**
+ * Calculates and returns the ratio
+ *
+ *
+ *
+ * $dimensions = new Dimensions(1200, 768);
+ * echo $dimensions->ratio();
+ * // output: 1.5625
+ *
+ *
+ *
+ * @return float
+ */
+ public function ratio(): float
+ {
+ if ($this->width !== 0 && $this->height !== 0) {
+ return $this->width / $this->height;
+ }
+
+ return 0;
+ }
+
+ /**
+ * @param int $width
+ * @param int $height
+ * @param bool $force
+ * @return self
+ */
+ public function resize(int $width = null, int $height = null, bool $force = false)
+ {
+ return $this->fitWidthAndHeight($width, $height, $force);
+ }
+
+ /**
+ * Checks if the dimensions are square
+ *
+ * @return bool
+ */
+ public function square(): bool
+ {
+ return $this->width == $this->height;
+ }
+
+ /**
+ * Resize and crop
+ *
+ * @param array $options
+ * @return self
+ */
+ public function thumb(array $options = [])
+ {
+ $width = $options['width'] ?? null;
+ $height = $options['height'] ?? null;
+ $crop = $options['crop'] ?? false;
+ $method = $crop !== false ? 'crop' : 'resize';
+
+ if ($width === null && $height === null) {
+ return $this;
+ }
+
+ return $this->$method($width, $height);
+ }
+
+ /**
+ * Converts the dimensions object
+ * to a plain PHP array
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return [
+ 'width' => $this->width(),
+ 'height' => $this->height(),
+ 'ratio' => $this->ratio(),
+ 'orientation' => $this->orientation(),
+ ];
+ }
+
+ /**
+ * Returns the width
+ *
+ * @return int
+ */
+ public function width(): int
+ {
+ return $this->width;
+ }
+}
diff --git a/kirby/src/Image/Exif.php b/kirby/src/Image/Exif.php
new file mode 100755
index 0000000..08aeef8
--- /dev/null
+++ b/kirby/src/Image/Exif.php
@@ -0,0 +1,296 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Exif
+{
+ /**
+ * the parent image object
+ * @var Image
+ */
+ protected $image;
+
+ /**
+ * the raw exif array
+ * @var array
+ */
+ protected $data = [];
+
+ /**
+ * the camera object with model and make
+ * @var Camera
+ */
+ protected $camera;
+
+ /**
+ * the location object
+ * @var Location
+ */
+ protected $location;
+
+ /**
+ * the timestamp
+ *
+ * @var string
+ */
+ protected $timestamp;
+
+ /**
+ * the exposure value
+ *
+ * @var string
+ */
+ protected $exposure;
+
+ /**
+ * the aperture value
+ *
+ * @var string
+ */
+ protected $aperture;
+
+ /**
+ * iso value
+ *
+ * @var string
+ */
+ protected $iso;
+
+ /**
+ * focal length
+ *
+ * @var string
+ */
+ protected $focalLength;
+
+ /**
+ * color or black/white
+ * @var bool
+ */
+ protected $isColor;
+
+ /**
+ * Constructor
+ *
+ * @param \Kirby\Image\Image $image
+ */
+ public function __construct(Image $image)
+ {
+ $this->image = $image;
+ $this->data = $this->read();
+ $this->parse();
+ }
+
+ /**
+ * Returns the raw data array from the parser
+ *
+ * @return array
+ */
+ public function data(): array
+ {
+ return $this->data;
+ }
+
+ /**
+ * Returns the Camera object
+ *
+ * @return \Kirby\Image\Camera|null
+ */
+ public function camera()
+ {
+ if ($this->camera !== null) {
+ return $this->camera;
+ }
+
+ return $this->camera = new Camera($this->data);
+ }
+
+ /**
+ * Returns the location object
+ *
+ * @return \Kirby\Image\Location|null
+ */
+ public function location()
+ {
+ if ($this->location !== null) {
+ return $this->location;
+ }
+
+ return $this->location = new Location($this->data);
+ }
+
+ /**
+ * Returns the timestamp
+ *
+ * @return string|null
+ */
+ public function timestamp()
+ {
+ return $this->timestamp;
+ }
+
+ /**
+ * Returns the exposure
+ *
+ * @return string|null
+ */
+ public function exposure()
+ {
+ return $this->exposure;
+ }
+
+ /**
+ * Returns the aperture
+ *
+ * @return string|null
+ */
+ public function aperture()
+ {
+ return $this->aperture;
+ }
+
+ /**
+ * Returns the iso value
+ *
+ * @return int|null
+ */
+ public function iso()
+ {
+ return $this->iso;
+ }
+
+ /**
+ * Checks if this is a color picture
+ *
+ * @return bool|null
+ */
+ public function isColor()
+ {
+ return $this->isColor;
+ }
+
+ /**
+ * Checks if this is a bw picture
+ *
+ * @return bool|null
+ */
+ public function isBW(): bool
+ {
+ return ($this->isColor !== null) ? $this->isColor === false : null;
+ }
+
+ /**
+ * Returns the focal length
+ *
+ * @return string|null
+ */
+ public function focalLength()
+ {
+ return $this->focalLength;
+ }
+
+ /**
+ * Read the exif data of the image object if possible
+ *
+ * @return mixed
+ */
+ protected function read(): array
+ {
+ if (function_exists('exif_read_data') === false) {
+ return [];
+ }
+
+ $data = @exif_read_data($this->image->root());
+ return is_array($data) ? $data : [];
+ }
+
+ /**
+ * Get all computed data
+ *
+ * @return array
+ */
+ protected function computed(): array
+ {
+ return $this->data['COMPUTED'] ?? [];
+ }
+
+ /**
+ * Pareses and stores all relevant exif data
+ */
+ protected function parse()
+ {
+ $this->timestamp = $this->parseTimestamp();
+ $this->exposure = $this->data['ExposureTime'] ?? null;
+ $this->iso = $this->data['ISOSpeedRatings'] ?? null;
+ $this->focalLength = $this->parseFocalLength();
+ $this->aperture = $this->computed()['ApertureFNumber'] ?? null;
+ $this->isColor = V::accepted($this->computed()['IsColor'] ?? null);
+ }
+
+ /**
+ * Return the timestamp when the picture has been taken
+ *
+ * @return string|int
+ */
+ protected function parseTimestamp()
+ {
+ if (isset($this->data['DateTimeOriginal']) === true) {
+ return strtotime($this->data['DateTimeOriginal']);
+ }
+
+ return $this->data['FileDateTime'] ?? $this->image->modified();
+ }
+
+ /**
+ * Teturn the focal length
+ *
+ * @return string|null
+ */
+ protected function parseFocalLength()
+ {
+ return $this->data['FocalLength'] ?? $this->data['FocalLengthIn35mmFilm'] ?? null;
+ }
+
+ /**
+ * Converts the object into a nicely readable array
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return [
+ 'camera' => $this->camera() ? $this->camera()->toArray() : null,
+ 'location' => $this->location() ? $this->location()->toArray() : null,
+ 'timestamp' => $this->timestamp(),
+ 'exposure' => $this->exposure(),
+ 'aperture' => $this->aperture(),
+ 'iso' => $this->iso(),
+ 'focalLength' => $this->focalLength(),
+ 'isColor' => $this->isColor()
+ ];
+ }
+
+ /**
+ * Improved `var_dump` output
+ *
+ * @return array
+ */
+ public function __debugInfo(): array
+ {
+ return array_merge($this->toArray(), [
+ 'camera' => $this->camera(),
+ 'location' => $this->location()
+ ]);
+ }
+}
diff --git a/kirby/src/Image/Image.php b/kirby/src/Image/Image.php
new file mode 100755
index 0000000..def07dc
--- /dev/null
+++ b/kirby/src/Image/Image.php
@@ -0,0 +1,310 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Image extends File
+{
+ /**
+ * optional url where the file is reachable
+ * @var string
+ */
+ protected $url;
+
+ /**
+ * @var Exif|null
+ */
+ protected $exif;
+
+ /**
+ * @var Dimensions|null
+ */
+ protected $dimensions;
+
+ /**
+ * Constructor
+ *
+ * @param string $root
+ * @param string|null $url
+ */
+ public function __construct(string $root = null, string $url = null)
+ {
+ parent::__construct($root);
+ $this->url = $url;
+ }
+
+ /**
+ * Improved `var_dump` output
+ *
+ * @return array
+ */
+ public function __debugInfo(): array
+ {
+ return array_merge($this->toArray(), [
+ 'dimensions' => $this->dimensions(),
+ 'exif' => $this->exif(),
+ ]);
+ }
+
+ /**
+ * Returns a full link to this file
+ * Perfect for debugging in connection with echo
+ *
+ * @return string
+ */
+ public function __toString(): string
+ {
+ return $this->root;
+ }
+
+ /**
+ * Returns the dimensions of the file if possible
+ *
+ * @return \Kirby\Image\Dimensions
+ */
+ public function dimensions()
+ {
+ if ($this->dimensions !== null) {
+ return $this->dimensions;
+ }
+
+ if (in_array($this->mime(), ['image/jpeg', 'image/jp2', 'image/png', 'image/gif', 'image/webp'])) {
+ return $this->dimensions = Dimensions::forImage($this->root);
+ }
+
+ if ($this->extension() === 'svg') {
+ return $this->dimensions = Dimensions::forSvg($this->root);
+ }
+
+ return $this->dimensions = new Dimensions(0, 0);
+ }
+
+ /*
+ * Automatically sends all needed headers for the file to be downloaded
+ * and echos the file's content
+ *
+ * @param string|null $filename Optional filename for the download
+ * @return string
+ */
+ public function download($filename = null): string
+ {
+ return Response::download($this->root, $filename ?? $this->filename());
+ }
+
+ /**
+ * Returns the exif object for this file (if image)
+ *
+ * @return \Kirby\Image\Exif
+ */
+ public function exif()
+ {
+ if ($this->exif !== null) {
+ return $this->exif;
+ }
+ $this->exif = new Exif($this);
+ return $this->exif;
+ }
+
+ /**
+ * Sends an appropriate header for the asset
+ *
+ * @param bool $send
+ * @return \Kirby\Http\Response|string
+ */
+ public function header(bool $send = true)
+ {
+ $response = new Response();
+ $response->type($this->mime());
+ return $send === true ? $response->send() : $response;
+ }
+
+ /**
+ * Returns the height of the asset
+ *
+ * @return int
+ */
+ public function height(): int
+ {
+ return $this->dimensions()->height();
+ }
+
+ /**
+ * @param array $attr
+ * @return string
+ */
+ public function html(array $attr = []): string
+ {
+ return Html::img($this->url(), $attr);
+ }
+
+ /**
+ * Returns the PHP imagesize array
+ *
+ * @return array
+ */
+ public function imagesize(): array
+ {
+ return getimagesize($this->root);
+ }
+
+ /**
+ * Checks if the dimensions of the asset are portrait
+ *
+ * @return bool
+ */
+ public function isPortrait(): bool
+ {
+ return $this->dimensions()->portrait();
+ }
+
+ /**
+ * Checks if the dimensions of the asset are landscape
+ *
+ * @return bool
+ */
+ public function isLandscape(): bool
+ {
+ return $this->dimensions()->landscape();
+ }
+
+ /**
+ * Checks if the dimensions of the asset are square
+ *
+ * @return bool
+ */
+ public function isSquare(): bool
+ {
+ return $this->dimensions()->square();
+ }
+
+ /**
+ * Runs a set of validations on the image object
+ *
+ * @param array $rules
+ * @return bool
+ */
+ public function match(array $rules): bool
+ {
+ if (($rules['mime'] ?? null) !== null) {
+ if (Mime::isAccepted($this->mime(), $rules['mime']) !== true) {
+ throw new Exception(I18n::template('error.file.mime.invalid', [
+ 'mime' => $this->mime()
+ ]));
+ }
+ }
+
+ $rules = array_change_key_case($rules);
+
+ $validations = [
+ 'maxsize' => ['size', 'max'],
+ 'minsize' => ['size', 'min'],
+ 'maxwidth' => ['width', 'max'],
+ 'minwidth' => ['width', 'min'],
+ 'maxheight' => ['height', 'max'],
+ 'minheight' => ['height', 'min'],
+ 'orientation' => ['orientation', 'same']
+ ];
+
+ foreach ($validations as $key => $arguments) {
+ $rule = $rules[$key] ?? null;
+
+ if ($rule !== null) {
+ $property = $arguments[0];
+ $validator = $arguments[1];
+
+ if (V::$validator($this->$property(), $rule) === false) {
+ throw new Exception(I18n::template('error.file.' . $key, [
+ $property => $rule
+ ]));
+ }
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns the ratio of the asset
+ *
+ * @return float
+ */
+ public function ratio(): float
+ {
+ return $this->dimensions()->ratio();
+ }
+
+ /**
+ * Returns the orientation as string
+ * landscape | portrait | square
+ *
+ * @return string
+ */
+ public function orientation(): string
+ {
+ return $this->dimensions()->orientation();
+ }
+
+ /**
+ * Converts the media object to a
+ * plain PHP array
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return array_merge(parent::toArray(), [
+ 'dimensions' => $this->dimensions()->toArray(),
+ 'exif' => $this->exif()->toArray(),
+ ]);
+ }
+
+ /**
+ * Converts the entire file array into
+ * a json string
+ *
+ * @return string
+ */
+ public function toJson(): string
+ {
+ return json_encode($this->toArray());
+ }
+
+ /**
+ * Returns the url
+ *
+ * @return string
+ */
+ public function url()
+ {
+ return $this->url;
+ }
+
+ /**
+ * Returns the width of the asset
+ *
+ * @return int
+ */
+ public function width(): int
+ {
+ return $this->dimensions()->width();
+ }
+}
diff --git a/kirby/src/Image/Location.php b/kirby/src/Image/Location.php
new file mode 100755
index 0000000..2c4e386
--- /dev/null
+++ b/kirby/src/Image/Location.php
@@ -0,0 +1,136 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Location
+{
+ /**
+ * latitude
+ *
+ * @var float|null
+ */
+ protected $lat;
+
+ /**
+ * longitude
+ *
+ * @var float|null
+ */
+ protected $lng;
+
+ /**
+ * Constructor
+ *
+ * @param array $exif The entire exif array
+ */
+ public function __construct(array $exif)
+ {
+ if (isset($exif['GPSLatitude']) === true &&
+ isset($exif['GPSLatitudeRef']) === true &&
+ isset($exif['GPSLongitude']) === true &&
+ isset($exif['GPSLongitudeRef']) === true
+ ) {
+ $this->lat = $this->gps($exif['GPSLatitude'], $exif['GPSLatitudeRef']);
+ $this->lng = $this->gps($exif['GPSLongitude'], $exif['GPSLongitudeRef']);
+ }
+ }
+
+ /**
+ * Returns the latitude
+ *
+ * @return float|null
+ */
+ public function lat()
+ {
+ return $this->lat;
+ }
+
+ /**
+ * Returns the longitude
+ *
+ * @return float|null
+ */
+ public function lng()
+ {
+ return $this->lng;
+ }
+
+ /**
+ * Converts the gps coordinates
+ *
+ * @param string|array $coord
+ * @param string $hemi
+ * @return float
+ */
+ protected function gps($coord, string $hemi): float
+ {
+ $degrees = count($coord) > 0 ? $this->num($coord[0]) : 0;
+ $minutes = count($coord) > 1 ? $this->num($coord[1]) : 0;
+ $seconds = count($coord) > 2 ? $this->num($coord[2]) : 0;
+
+ $hemi = strtoupper($hemi);
+ $flip = ($hemi == 'W' || $hemi == 'S') ? -1 : 1;
+
+ return $flip * ($degrees + $minutes / 60 + $seconds / 3600);
+ }
+
+ /**
+ * Converts coordinates to floats
+ *
+ * @param string $part
+ * @return float
+ */
+ protected function num(string $part): float
+ {
+ $parts = explode('/', $part);
+
+ if (count($parts) == 1) {
+ return $parts[0];
+ }
+
+ return (float)($parts[0]) / (float)($parts[1]);
+ }
+
+ /**
+ * Converts the object into a nicely readable array
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return [
+ 'lat' => $this->lat(),
+ 'lng' => $this->lng()
+ ];
+ }
+
+ /**
+ * Echos the entire location as lat, lng
+ *
+ * @return string
+ */
+ public function __toString(): string
+ {
+ return trim(trim($this->lat() . ', ' . $this->lng(), ','));
+ }
+
+ /**
+ * Improved `var_dump` output
+ *
+ * @return array
+ */
+ public function __debugInfo(): array
+ {
+ return $this->toArray();
+ }
+}
diff --git a/kirby/src/Session/AutoSession.php b/kirby/src/Session/AutoSession.php
new file mode 100755
index 0000000..cf9b33a
--- /dev/null
+++ b/kirby/src/Session/AutoSession.php
@@ -0,0 +1,171 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class AutoSession
+{
+ protected $sessions;
+ protected $options;
+
+ protected $createdSession;
+
+ /**
+ * Creates a new AutoSession instance
+ *
+ * @param \Kirby\Session\SessionStore|string $store SessionStore object or a path to the storage directory (uses the FileSessionStore)
+ * @param array $options Optional additional options:
+ * - `durationNormal`: Duration of normal sessions in seconds; defaults to 2 hours
+ * - `durationLong`: Duration of "remember me" sessions in seconds; defaults to 2 weeks
+ * - `timeout`: Activity timeout in seconds (integer or false for none); *only* used for normal sessions; defaults to `1800` (half an hour)
+ * - `cookieName`: Name to use for the session cookie; defaults to `kirby_session`
+ * - `gcInterval`: How often should the garbage collector be run?; integer or `false` for never; defaults to `100`
+ */
+ public function __construct($store, array $options = [])
+ {
+ // merge options with defaults
+ $this->options = array_merge([
+ 'durationNormal' => 7200,
+ 'durationLong' => 1209600,
+ 'timeout' => 1800,
+ 'cookieName' => 'kirby_session',
+ 'gcInterval' => 100
+ ], $options);
+
+ // create an internal instance of the low-level Sessions class
+ $this->sessions = new Sessions($store, [
+ 'cookieName' => $this->options['cookieName'],
+ 'gcInterval' => $this->options['gcInterval']
+ ]);
+ }
+
+ /**
+ * Returns the automatic session
+ *
+ * @param array $options Optional additional options:
+ * - `detect`: Whether to allow sessions in the `Authorization` HTTP header (`true`) or only in the session cookie (`false`); defaults to `false`
+ * - `createMode`: When creating a new session, should it be set as a cookie or is it going to be transmitted manually to be used in a header?; defaults to `cookie`
+ * - `long`: Whether the session is a long "remember me" session or a normal session; defaults to `false`
+ * @return \Kirby\Session\Session
+ */
+ public function get(array $options = [])
+ {
+ // merge options with defaults
+ $options = array_merge([
+ 'detect' => false,
+ 'createMode' => 'cookie',
+ 'long' => false
+ ], $options);
+
+ // determine expiry options based on the session type
+ if ($options['long'] === true) {
+ $duration = $this->options['durationLong'];
+ $timeout = false;
+ } else {
+ $duration = $this->options['durationNormal'];
+ $timeout = $this->options['timeout'];
+ }
+
+ // get the current session
+ if ($options['detect'] === true) {
+ $session = $this->sessions->currentDetected();
+ } else {
+ $session = $this->sessions->current();
+ }
+
+ // create a new session
+ if ($session === null) {
+ $session = $this->createdSession ?? $this->sessions->create([
+ 'mode' => $options['createMode'],
+ 'startTime' => time(),
+ 'expiryTime' => time() + $duration,
+ 'timeout' => $timeout,
+ 'renewable' => true,
+ ]);
+
+ // cache the newly created session to ensure that we don't create multiple
+ $this->createdSession = $session;
+ }
+
+ // update the session configuration if the $options changed
+ // always use the less strict value for compatibility with features
+ // that depend on the less strict behavior
+ if ($duration > $session->duration()) {
+ // the duration needs to be extended
+ $session->duration($duration);
+ }
+ if ($session->timeout() !== false) {
+ // a timeout exists
+ if ($timeout === false) {
+ // it needs to be completely disabled
+ $session->timeout(false);
+ } elseif (is_int($timeout) && $timeout > $session->timeout()) {
+ // it needs to be extended
+ $session->timeout($timeout);
+ }
+ }
+
+ // if the session has been created and was not yet initialized,
+ // update the mode to a custom mode
+ // don't update back to cookie mode because the "special" behavior always wins
+ if ($session->token() === null && $options['createMode'] !== 'cookie') {
+ $session->mode($options['createMode']);
+ }
+
+ return $session;
+ }
+
+ /**
+ * Creates a new empty session that is *not* automatically transmitted to the client
+ * Useful for custom applications like a password reset link
+ * Does *not* affect the automatic session
+ *
+ * @param array $options Optional additional options:
+ * - `startTime`: Time the session starts being valid (date string or timestamp); defaults to `now`
+ * - `expiryTime`: Time the session expires (date string or timestamp); defaults to `+ 2 hours`
+ * - `timeout`: Activity timeout in seconds (integer or false for none); defaults to `1800` (half an hour)
+ * - `renewable`: Should it be possible to extend the expiry date?; defaults to `true`
+ * @return \Kirby\Session\Session
+ */
+ public function createManually(array $options = [])
+ {
+ // only ever allow manual transmission mode
+ // to prevent overwriting our "auto" session
+ $options['mode'] = 'manual';
+
+ return $this->sessions->create($options);
+ }
+
+ /**
+ * Returns the specified Session object
+ * @since 3.3.1
+ *
+ * @param string $token Session token, either including or without the key
+ * @return \Kirby\Session\Session
+ */
+ public function getManually(string $token)
+ {
+ return $this->sessions->get($token, 'manual');
+ }
+
+ /**
+ * Deletes all expired sessions
+ *
+ * If the `gcInterval` is configured, this is done automatically
+ * when intializing the AutoSession class
+ *
+ * @return void
+ */
+ public function collectGarbage()
+ {
+ $this->sessions->collectGarbage();
+ }
+}
diff --git a/kirby/src/Session/FileSessionStore.php b/kirby/src/Session/FileSessionStore.php
new file mode 100755
index 0000000..aeaee14
--- /dev/null
+++ b/kirby/src/Session/FileSessionStore.php
@@ -0,0 +1,484 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class FileSessionStore extends SessionStore
+{
+ protected $path;
+
+ // state of the session files
+ protected $handles = [];
+ protected $isLocked = [];
+
+ /**
+ * Creates a new instance of the file session store
+ *
+ * @param string $path Path to the storage directory
+ */
+ public function __construct(string $path)
+ {
+ // create the directory if it doesn't already exist
+ Dir::make($path, true);
+
+ // store the canonicalized path
+ $this->path = realpath($path);
+
+ // make sure it is usable for storage
+ if (!is_writable($this->path)) {
+ throw new Exception([
+ 'key' => 'session.filestore.dirNotWritable',
+ 'data' => ['path' => $this->path],
+ 'fallback' => 'The session storage directory "' . $path . '" is not writable',
+ 'translate' => false,
+ 'httpCode' => 500
+ ]);
+ }
+ }
+
+ /**
+ * Creates a new session ID with the given expiry time
+ *
+ * Needs to make sure that the session does not already exist
+ * and needs to reserve it by locking it exclusively.
+ *
+ * @param int $expiryTime Timestamp
+ * @return string Randomly generated session ID (without timestamp)
+ */
+ public function createId(int $expiryTime): string
+ {
+ clearstatcache();
+ do {
+ // use helper from the abstract SessionStore class
+ $id = static::generateId();
+
+ $name = $this->name($expiryTime, $id);
+ $path = $this->path($name);
+ } while (file_exists($path));
+
+ // reserve the file
+ touch($path);
+ $this->lock($expiryTime, $id);
+
+ // ensure that no other thread already wrote to the same file, otherwise try again
+ // very unlikely scenario!
+ $contents = $this->get($expiryTime, $id);
+ if ($contents !== '') {
+ // @codeCoverageIgnoreStart
+ $this->unlock($expiryTime, $id);
+ return $this->createId($expiryTime);
+ // @codeCoverageIgnoreEnd
+ }
+
+ return $id;
+ }
+
+ /**
+ * Checks if the given session exists
+ *
+ * @param int $expiryTime Timestamp
+ * @param string $id Session ID
+ * @return bool true: session exists,
+ * false: session doesn't exist
+ */
+ public function exists(int $expiryTime, string $id): bool
+ {
+ $name = $this->name($expiryTime, $id);
+ $path = $this->path($name);
+
+ clearstatcache();
+ return is_file($path) === true;
+ }
+
+ /**
+ * Locks the given session exclusively
+ *
+ * Needs to throw an Exception on error.
+ *
+ * @param int $expiryTime Timestamp
+ * @param string $id Session ID
+ * @return void
+ */
+ public function lock(int $expiryTime, string $id)
+ {
+ $name = $this->name($expiryTime, $id);
+ $path = $this->path($name);
+
+ // check if the file is already locked
+ if (isset($this->isLocked[$name])) {
+ return;
+ }
+
+ // lock it exclusively
+ $handle = $this->handle($name);
+ $result = flock($handle, LOCK_EX);
+
+ // make a note that the file is now locked
+ if ($result === true) {
+ $this->isLocked[$name] = true;
+ } else {
+ // @codeCoverageIgnoreStart
+ throw new Exception([
+ 'key' => 'session.filestore.unexpectedFilesystemError',
+ 'fallback' => 'Unexpected file system error',
+ 'translate' => false,
+ 'httpCode' => 500
+ ]);
+ // @codeCoverageIgnoreEnd
+ }
+ }
+
+ /**
+ * Removes all locks on the given session
+ *
+ * Needs to throw an Exception on error.
+ *
+ * @param int $expiryTime Timestamp
+ * @param string $id Session ID
+ * @return void
+ */
+ public function unlock(int $expiryTime, string $id)
+ {
+ $name = $this->name($expiryTime, $id);
+ $path = $this->path($name);
+
+ // check if the file is already unlocked or doesn't exist
+ if (!isset($this->isLocked[$name])) {
+ return;
+ } elseif ($this->exists($expiryTime, $id) === false) {
+ unset($this->isLocked[$name]);
+ return;
+ }
+
+ // remove the exclusive lock
+ $handle = $this->handle($name);
+ $result = flock($handle, LOCK_UN);
+
+ // make a note that the file is no longer locked
+ if ($result === true) {
+ unset($this->isLocked[$name]);
+ } else {
+ // @codeCoverageIgnoreStart
+ throw new Exception([
+ 'key' => 'session.filestore.unexpectedFilesystemError',
+ 'fallback' => 'Unexpected file system error',
+ 'translate' => false,
+ 'httpCode' => 500
+ ]);
+ // @codeCoverageIgnoreEnd
+ }
+ }
+
+ /**
+ * Returns the stored session data of the given session
+ *
+ * Needs to throw an Exception on error.
+ *
+ * @param int $expiryTime Timestamp
+ * @param string $id Session ID
+ * @return string
+ */
+ public function get(int $expiryTime, string $id): string
+ {
+ $name = $this->name($expiryTime, $id);
+ $path = $this->path($name);
+ $handle = $this->handle($name);
+
+ // set read lock to prevent other threads from corrupting the data while we read it
+ // only if we don't already have a write lock, which is even better
+ if (!isset($this->isLocked[$name])) {
+ $result = flock($handle, LOCK_SH);
+
+ if ($result !== true) {
+ // @codeCoverageIgnoreStart
+ throw new Exception([
+ 'key' => 'session.filestore.unexpectedFilesystemError',
+ 'fallback' => 'Unexpected file system error',
+ 'translate' => false,
+ 'httpCode' => 500
+ ]);
+ // @codeCoverageIgnoreEnd
+ }
+ }
+
+ clearstatcache();
+ $filesize = filesize($path);
+ if ($filesize > 0) {
+ // always read the whole file
+ rewind($handle);
+ $string = fread($handle, $filesize);
+ } else {
+ // we don't need to read empty files
+ $string = '';
+ }
+
+ // remove the shared lock if we set one above
+ if (!isset($this->isLocked[$name])) {
+ $result = flock($handle, LOCK_UN);
+
+ if ($result !== true) {
+ // @codeCoverageIgnoreStart
+ throw new Exception([
+ 'key' => 'session.filestore.unexpectedFilesystemError',
+ 'fallback' => 'Unexpected file system error',
+ 'translate' => false,
+ 'httpCode' => 500
+ ]);
+ // @codeCoverageIgnoreEnd
+ }
+ }
+
+ return $string;
+ }
+
+ /**
+ * Stores data to the given session
+ *
+ * Needs to make sure that the session exists.
+ * Needs to throw an Exception on error.
+ *
+ * @param int $expiryTime Timestamp
+ * @param string $id Session ID
+ * @param string $data Session data to write
+ * @return void
+ */
+ public function set(int $expiryTime, string $id, string $data)
+ {
+ $name = $this->name($expiryTime, $id);
+ $path = $this->path($name);
+ $handle = $this->handle($name);
+
+ // validate that we have an exclusive lock already
+ if (!isset($this->isLocked[$name])) {
+ throw new LogicException([
+ 'key' => 'session.filestore.notLocked',
+ 'data' => ['name' => $name],
+ 'fallback' => 'Cannot write to session "' . $name . '", because it is not locked',
+ 'translate' => false,
+ 'httpCode' => 500
+ ]);
+ }
+
+ // delete all file contents first
+ if (rewind($handle) !== true || ftruncate($handle, 0) !== true) {
+ // @codeCoverageIgnoreStart
+ throw new Exception([
+ 'key' => 'session.filestore.unexpectedFilesystemError',
+ 'fallback' => 'Unexpected file system error',
+ 'translate' => false,
+ 'httpCode' => 500
+ ]);
+ // @codeCoverageIgnoreEnd
+ }
+
+ // write the new contents
+ $result = fwrite($handle, $data);
+ if (!is_int($result) || $result === 0) {
+ // @codeCoverageIgnoreStart
+ throw new Exception([
+ 'key' => 'session.filestore.unexpectedFilesystemError',
+ 'fallback' => 'Unexpected file system error',
+ 'translate' => false,
+ 'httpCode' => 500
+ ]);
+ // @codeCoverageIgnoreEnd
+ }
+ }
+
+ /**
+ * Deletes the given session
+ *
+ * Needs to throw an Exception on error.
+ *
+ * @param int $expiryTime Timestamp
+ * @param string $id Session ID
+ * @return void
+ */
+ public function destroy(int $expiryTime, string $id)
+ {
+ $name = $this->name($expiryTime, $id);
+ $path = $this->path($name);
+
+ // close the file, otherwise we can't delete it on Windows;
+ // deletion is *not* thread-safe because of this, but
+ // resurrection of the file is prevented in $this->set() because of
+ // the check in $this->handle() every time any method is called
+ $this->unlock($expiryTime, $id);
+ $this->closeHandle($name);
+
+ // we don't need to delete files that don't exist anymore
+ if ($this->exists($expiryTime, $id) === false) {
+ return;
+ }
+
+ // file still exists, delete it
+ if (@unlink($path) !== true) {
+ // @codeCoverageIgnoreStart
+ throw new Exception([
+ 'key' => 'session.filestore.unexpectedFilesystemError',
+ 'fallback' => 'Unexpected file system error',
+ 'translate' => false,
+ 'httpCode' => 500
+ ]);
+ // @codeCoverageIgnoreEnd
+ }
+ }
+
+ /**
+ * Deletes all expired sessions
+ *
+ * Needs to throw an Exception on error.
+ *
+ * @return void
+ */
+ public function collectGarbage()
+ {
+ $iterator = new FilesystemIterator($this->path);
+
+ $currentTime = time();
+ foreach ($iterator as $file) {
+ // make sure that the file is a session file
+ // prevents deleting files like .gitignore or other unrelated files
+ if (preg_match('/^[0-9]+\.[a-z0-9]+\.sess$/', $file->getFilename()) !== 1) {
+ continue;
+ }
+
+ // extract the data from the filename
+ $name = $file->getBasename('.sess');
+ $expiryTime = (int)Str::before($name, '.');
+ $id = Str::after($name, '.');
+
+ if ($expiryTime < $currentTime) {
+ // the session has expired, delete it
+ $this->destroy($expiryTime, $id);
+ }
+ }
+ }
+
+ /**
+ * Cleans up the open locks and file handles
+ *
+ * @codeCoverageIgnore
+ */
+ public function __destruct()
+ {
+ // unlock all locked files
+ foreach ($this->isLocked as $name => $locked) {
+ $expiryTime = (int)Str::before($name, '.');
+ $id = Str::after($name, '.');
+
+ $this->unlock($expiryTime, $id);
+ }
+
+ // close all file handles
+ foreach ($this->handles as $name => $handle) {
+ $this->closeHandle($name);
+ }
+ }
+
+ /**
+ * Returns the combined name based on expiry time and ID
+ *
+ * @param int $expiryTime Timestamp
+ * @param string $id Session ID
+ * @return string
+ */
+ protected function name(int $expiryTime, string $id): string
+ {
+ return $expiryTime . '.' . $id;
+ }
+
+ /**
+ * Returns the full path to the session file
+ *
+ * @param string $name Combined name
+ * @return string
+ */
+ protected function path(string $name): string
+ {
+ return $this->path . '/' . $name . '.sess';
+ }
+
+ /**
+ * Returns a PHP file handle for a session
+ *
+ * @param string $name Combined name
+ * @return resource File handle
+ */
+ protected function handle(string $name)
+ {
+ // always verify that the file still exists, even if we already have a handle;
+ // ensures thread-safeness for recently deleted sessions, see $this->destroy()
+ $path = $this->path($name);
+ clearstatcache();
+ if (!is_file($path)) {
+ throw new NotFoundException([
+ 'key' => 'session.filestore.notFound',
+ 'data' => ['name' => $name],
+ 'fallback' => 'Session file "' . $name . '" does not exist',
+ 'translate' => false,
+ 'httpCode' => 404
+ ]);
+ }
+
+ // return from cache
+ if (isset($this->handles[$name])) {
+ return $this->handles[$name];
+ }
+
+ // open a new handle
+ $handle = @fopen($path, 'r+b');
+ if (!is_resource($handle)) {
+ throw new Exception([
+ 'key' => 'session.filestore.notOpened',
+ 'data' => ['name' => $name],
+ 'fallback' => 'Session file "' . $name . '" could not be opened',
+ 'translate' => false,
+ 'httpCode' => 500
+ ]);
+ }
+
+ return $this->handles[$name] = $handle;
+ }
+
+ /**
+ * Closes an open file handle
+ *
+ * @param string $name Combined name
+ * @return void
+ */
+ protected function closeHandle(string $name)
+ {
+ if (!isset($this->handles[$name])) {
+ return;
+ }
+ $handle = $this->handles[$name];
+
+ unset($this->handles[$name]);
+ $result = fclose($handle);
+
+ if ($result !== true) {
+ // @codeCoverageIgnoreStart
+ throw new Exception([
+ 'key' => 'session.filestore.unexpectedFilesystemError',
+ 'fallback' => 'Unexpected file system error',
+ 'translate' => false,
+ 'httpCode' => 500
+ ]);
+ // @codeCoverageIgnoreEnd
+ }
+ }
+}
diff --git a/kirby/src/Session/Session.php b/kirby/src/Session/Session.php
new file mode 100755
index 0000000..3bdb0e8
--- /dev/null
+++ b/kirby/src/Session/Session.php
@@ -0,0 +1,766 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Session
+{
+ // parent data
+ protected $sessions;
+ protected $mode;
+
+ // parts of the token
+ protected $tokenExpiry;
+ protected $tokenId;
+ protected $tokenKey;
+
+ // persistent data
+ protected $startTime;
+ protected $expiryTime;
+ protected $duration;
+ protected $timeout;
+ protected $lastActivity;
+ protected $renewable;
+ protected $data;
+ protected $newSession;
+
+ // temporary state flags
+ protected $updatingLastActivity = false;
+ protected $destroyed = false;
+ protected $writeMode = false;
+ protected $needsRetransmission = false;
+
+ /**
+ * Creates a new Session instance
+ *
+ * @param \Kirby\Session\Sessions $sessions Parent sessions object
+ * @param string|null $token Session token or null for a new session
+ * @param array $options Optional additional options:
+ * - `mode`: Token transmission mode (cookie or manual); defaults to `cookie`
+ * - `startTime`: Time the session starts being valid (date string or timestamp); defaults to `now`
+ * - `expiryTime`: Time the session expires (date string or timestamp); defaults to `+ 2 hours`
+ * - `timeout`: Activity timeout in seconds (integer or false for none); defaults to `1800` (half an hour)
+ * - `renewable`: Should it be possible to extend the expiry date?; defaults to `true`
+ */
+ public function __construct(Sessions $sessions, $token, array $options)
+ {
+ $this->sessions = $sessions;
+ $this->mode = $options['mode'] ?? 'cookie';
+
+ if (is_string($token)) {
+ // existing session
+
+ // set the token as instance vars
+ $this->parseToken($token);
+
+ // initialize, but only try to write to the session if not read-only
+ // (only the case for moved sessions)
+ $this->init();
+ if ($this->tokenKey !== null) {
+ $this->autoRenew();
+ }
+ } elseif ($token === null) {
+ // new session
+
+ // set data based on options
+ $this->startTime = static::timeToTimestamp($options['startTime'] ?? time());
+ $this->expiryTime = static::timeToTimestamp($options['expiryTime'] ?? '+ 2 hours', $this->startTime);
+ $this->duration = $this->expiryTime - $this->startTime;
+ $this->timeout = $options['timeout'] ?? 1800;
+ $this->renewable = $options['renewable'] ?? true;
+ $this->data = new SessionData($this, []);
+
+ // validate persistent data
+ if (time() > $this->expiryTime) {
+ // session must not already be expired, but the start time may be in the future
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'Session::__construct', 'argument' => '$options[\'expiryTime\']'],
+ 'translate' => false
+ ]);
+ }
+ if ($this->duration < 0) {
+ // expiry time must be after start time
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'Session::__construct', 'argument' => '$options[\'startTime\' & \'expiryTime\']'],
+ 'translate' => false
+ ]);
+ }
+ if (!is_int($this->timeout) && $this->timeout !== false) {
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'Session::__construct', 'argument' => '$options[\'timeout\']'],
+ 'translate' => false
+ ]);
+ }
+ if (!is_bool($this->renewable)) {
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'Session::__construct', 'argument' => '$options[\'renewable\']'],
+ 'translate' => false
+ ]);
+ }
+
+ // set activity time if a timeout was requested
+ if (is_int($this->timeout)) {
+ $this->lastActivity = time();
+ }
+ } else {
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'Session::__construct', 'argument' => '$token'],
+ 'translate' => false
+ ]);
+ }
+
+ // ensure that all changes are committed on script termination
+ register_shutdown_function([$this, 'commit']);
+ }
+
+ /**
+ * Gets the session token or null if the session doesn't have a token yet
+ *
+ * @return string|null
+ */
+ public function token()
+ {
+ if ($this->tokenExpiry !== null) {
+ if (is_string($this->tokenKey)) {
+ return $this->tokenExpiry . '.' . $this->tokenId . '.' . $this->tokenKey;
+ } else {
+ return $this->tokenExpiry . '.' . $this->tokenId;
+ }
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Gets or sets the transmission mode
+ * Setting only works for new sessions that haven't been transmitted yet
+ *
+ * @param string $mode Optional new transmission mode
+ * @return string Transmission mode
+ */
+ public function mode(string $mode = null)
+ {
+ if (is_string($mode)) {
+ // only allow this if this is a new session, otherwise the change
+ // might not be applied correctly to the current request
+ if ($this->token() !== null) {
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'Session::mode', 'argument' => '$mode'],
+ 'translate' => false
+ ]);
+ }
+
+ $this->mode = $mode;
+ }
+
+ return $this->mode;
+ }
+
+ /**
+ * Gets the session start time
+ *
+ * @return int Timestamp
+ */
+ public function startTime(): int
+ {
+ return $this->startTime;
+ }
+
+ /**
+ * Gets or sets the session expiry time
+ * Setting the expiry time also updates the duration and regenerates the session token
+ *
+ * @param string|int $expiryTime Optional new expiry timestamp or time string to set
+ * @return int Timestamp
+ */
+ public function expiryTime($expiryTime = null): int
+ {
+ if (is_string($expiryTime) || is_int($expiryTime)) {
+ // convert to a timestamp
+ $expiryTime = static::timeToTimestamp($expiryTime);
+
+ // verify that the expiry time is not in the past
+ if ($expiryTime <= time()) {
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'Session::expiryTime', 'argument' => '$expiryTime'],
+ 'translate' => false
+ ]);
+ }
+
+ $this->prepareForWriting();
+ $this->expiryTime = $expiryTime;
+ $this->duration = $expiryTime - time();
+ $this->regenerateTokenIfNotNew();
+ } elseif ($expiryTime !== null) {
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'Session::expiryTime', 'argument' => '$expiryTime'],
+ 'translate' => false
+ ]);
+ }
+
+ return $this->expiryTime;
+ }
+
+ /**
+ * Gets or sets the session duration
+ * Setting the duration also updates the expiry time and regenerates the session token
+ *
+ * @param int $duration Optional new duration in seconds to set
+ * @return int Number of seconds
+ */
+ public function duration(int $duration = null): int
+ {
+ if (is_int($duration)) {
+ // verify that the duration is at least 1 second
+ if ($duration < 1) {
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'Session::duration', 'argument' => '$duration'],
+ 'translate' => false
+ ]);
+ }
+
+ $this->prepareForWriting();
+ $this->duration = $duration;
+ $this->expiryTime = time() + $duration;
+ $this->regenerateTokenIfNotNew();
+ }
+
+ return $this->duration;
+ }
+
+ /**
+ * Gets or sets the session timeout
+ *
+ * @param int|false $timeout Optional new timeout to set or false to disable timeout
+ * @return int|false Number of seconds or false for "no timeout"
+ */
+ public function timeout($timeout = null)
+ {
+ if (is_int($timeout) || $timeout === false) {
+ // verify that the timeout is at least 1 second
+ if (is_int($timeout) && $timeout < 1) {
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'Session::timeout', 'argument' => '$timeout'],
+ 'translate' => false
+ ]);
+ }
+
+ $this->prepareForWriting();
+ $this->timeout = $timeout;
+
+ if (is_int($timeout)) {
+ $this->lastActivity = time();
+ } else {
+ $this->lastActivity = null;
+ }
+ } elseif ($timeout !== null) {
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'Session::timeout', 'argument' => '$timeout'],
+ 'translate' => false
+ ]);
+ }
+
+ return $this->timeout;
+ }
+
+ /**
+ * Gets or sets the renewable flag
+ * Automatically renews the session if renewing gets enabled
+ *
+ * @param bool $renewable Optional new renewable flag to set
+ * @return bool
+ */
+ public function renewable(bool $renewable = null): bool
+ {
+ if (is_bool($renewable)) {
+ $this->prepareForWriting();
+ $this->renewable = $renewable;
+ $this->autoRenew();
+ }
+
+ return $this->renewable;
+ }
+
+ /**
+ * Returns the session data object
+ *
+ * @return \Kirby\Session\SessionData
+ */
+ public function data()
+ {
+ return $this->data;
+ }
+
+ /**
+ * Magic call method that proxies all calls to session data methods
+ *
+ * @param string $name Method name (one of set, increment, decrement, get, pull, remove, clear)
+ * @param array $arguments Method arguments
+ * @return mixed
+ */
+ public function __call(string $name, array $arguments)
+ {
+ // validate that we can handle the called method
+ if (!in_array($name, ['set', 'increment', 'decrement', 'get', 'pull', 'remove', 'clear'])) {
+ throw new BadMethodCallException([
+ 'data' => ['method' => 'Session::' . $name],
+ 'translate' => false
+ ]);
+ }
+
+ return $this->data()->$name(...$arguments);
+ }
+
+ /**
+ * Writes all changes to the session to the session store
+ *
+ * @return void
+ */
+ public function commit()
+ {
+ // nothing to do if nothing changed or the session has been just created or destroyed
+ if ($this->writeMode !== true || $this->tokenExpiry === null || $this->destroyed === true) {
+ return;
+ }
+
+ // collect all data
+ if ($this->newSession) {
+ // the token has changed
+ // we are writing to the old session: it only gets the reference to the new session
+ // and a shortened expiry time (30 second grace period)
+ $data = [
+ 'startTime' => $this->startTime(),
+ 'expiryTime' => time() + 30,
+ 'newSession' => $this->newSession
+ ];
+ } else {
+ $data = [
+ 'startTime' => $this->startTime(),
+ 'expiryTime' => $this->expiryTime(),
+ 'duration' => $this->duration(),
+ 'timeout' => $this->timeout(),
+ 'lastActivity' => $this->lastActivity,
+ 'renewable' => $this->renewable(),
+ 'data' => $this->data()->get()
+ ];
+ }
+
+ // encode the data and attach an HMAC
+ $data = serialize($data);
+ $data = hash_hmac('sha256', $data, $this->tokenKey) . "\n" . $data;
+
+ // store the data
+ $this->sessions->store()->set($this->tokenExpiry, $this->tokenId, $data);
+ $this->sessions->store()->unlock($this->tokenExpiry, $this->tokenId);
+ $this->writeMode = false;
+ }
+
+ /**
+ * Entirely destroys the session
+ *
+ * @return void
+ */
+ public function destroy()
+ {
+ // no need to destroy new or destroyed sessions
+ if ($this->tokenExpiry === null || $this->destroyed === true) {
+ return;
+ }
+
+ // remove session file
+ $this->sessions->store()->destroy($this->tokenExpiry, $this->tokenId);
+ $this->destroyed = true;
+ $this->writeMode = false;
+ $this->needsRetransmission = false;
+
+ // remove cookie
+ if ($this->mode === 'cookie') {
+ Cookie::remove($this->sessions->cookieName());
+ }
+ }
+
+ /**
+ * Renews the session with the same session duration
+ * Renewing also regenerates the session token
+ *
+ * @return void
+ */
+ public function renew()
+ {
+ if ($this->renewable() !== true) {
+ throw new LogicException([
+ 'key' => 'session.notRenewable',
+ 'fallback' => 'Cannot renew a session that is not renewable, call $session->renewable(true) first',
+ 'translate' => false,
+ ]);
+ }
+
+ $this->prepareForWriting();
+ $this->expiryTime = time() + $this->duration();
+ $this->regenerateTokenIfNotNew();
+ }
+
+ /**
+ * Regenerates the session token
+ * The old token will keep its validity for a 30 second grace period
+ *
+ * @return void
+ */
+ public function regenerateToken()
+ {
+ // don't do anything for destroyed sessions
+ if ($this->destroyed === true) {
+ return;
+ }
+
+ $this->prepareForWriting();
+
+ // generate new token
+ $tokenExpiry = $this->expiryTime;
+ $tokenId = $this->sessions->store()->createId($tokenExpiry);
+ $tokenKey = bin2hex(random_bytes(32));
+
+ // mark the old session as moved if there is one
+ if ($this->tokenExpiry !== null) {
+ $this->newSession = $tokenExpiry . '.' . $tokenId;
+ $this->commit();
+
+ // we are now in the context of the new session
+ $this->newSession = null;
+ }
+
+ // set new data as instance vars
+ $this->tokenExpiry = $tokenExpiry;
+ $this->tokenId = $tokenId;
+ $this->tokenKey = $tokenKey;
+
+ // the new session needs to be written for the first time
+ $this->writeMode = true;
+
+ // (re)transmit session token
+ if ($this->mode === 'cookie') {
+ Cookie::set($this->sessions->cookieName(), $this->token(), [
+ 'lifetime' => $this->tokenExpiry,
+ 'path' => Url::index(['host' => null, 'trailingSlash' => true]),
+ 'secure' => Url::scheme() === 'https',
+ 'httpOnly' => true
+ ]);
+ } else {
+ $this->needsRetransmission = true;
+ }
+
+ // update cache of the Sessions instance with the new token
+ $this->sessions->updateCache($this);
+ }
+
+ /**
+ * Returns whether the session token needs to be retransmitted to the client
+ * Only relevant in header and manual modes
+ *
+ * @return bool
+ */
+ public function needsRetransmission(): bool
+ {
+ return $this->needsRetransmission;
+ }
+
+ /**
+ * Ensures that all pending changes are written to disk before the object is destructed
+ */
+ public function __destruct()
+ {
+ $this->commit();
+ }
+
+ /**
+ * Initially generates the token for new sessions
+ * Used internally
+ *
+ * @return void
+ */
+ public function ensureToken()
+ {
+ if ($this->tokenExpiry === null) {
+ $this->regenerateToken();
+ }
+ }
+
+ /**
+ * Puts the session into write mode by acquiring a lock and reloading the data
+ * Used internally
+ *
+ * @return void
+ */
+ public function prepareForWriting()
+ {
+ // verify that we need to get into write mode:
+ // - new sessions are only written to if the token has explicitly been ensured
+ // using $session->ensureToken() -> lazy session creation
+ // - destroyed sessions are never written to
+ // - no need to lock and re-init if we are already in write mode
+ if ($this->tokenExpiry === null || $this->destroyed === true || $this->writeMode === true) {
+ return;
+ }
+
+ // don't allow writing for read-only sessions
+ // (only the case for moved sessions)
+ if ($this->tokenKey === null) {
+ throw new LogicException([
+ 'key' => 'session.readonly',
+ 'data' => ['token' => $this->token()],
+ 'fallback' => 'Session "' . $this->token() . '" is currently read-only because it was accessed via an old session token',
+ 'translate' => false
+ ]);
+ }
+
+ $this->sessions->store()->lock($this->tokenExpiry, $this->tokenId);
+ $this->init();
+ $this->writeMode = true;
+ }
+
+ /**
+ * Parses a token string into its parts and sets them as instance vars
+ *
+ * @param string $token Session token
+ * @param bool $withoutKey If true, $token is passed without key
+ * @return void
+ */
+ protected function parseToken(string $token, bool $withoutKey = false)
+ {
+ // split the token into its parts
+ $parts = explode('.', $token);
+
+ // only continue if the token has exactly the right amount of parts
+ $expectedParts = ($withoutKey === true)? 2 : 3;
+ if (count($parts) !== $expectedParts) {
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'Session::parseToken', 'argument' => '$token'],
+ 'translate' => false
+ ]);
+ }
+
+ $tokenExpiry = (int)$parts[0];
+ $tokenId = $parts[1];
+ $tokenKey = ($withoutKey === true)? null : $parts[2];
+
+ // verify that all parts were parsed correctly using reassembly
+ $expectedToken = $tokenExpiry . '.' . $tokenId;
+ if ($withoutKey === false) {
+ $expectedToken .= '.' . $tokenKey;
+ }
+ if ($expectedToken !== $token) {
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'Session::parseToken', 'argument' => '$token'],
+ 'translate' => false
+ ]);
+ }
+
+ $this->tokenExpiry = $tokenExpiry;
+ $this->tokenId = $tokenId;
+ $this->tokenKey = $tokenKey;
+ }
+
+ /**
+ * Makes sure that the given value is a valid timestamp
+ *
+ * @param string|int $time Timestamp or date string (must be supported by `strtotime()`)
+ * @param int $now Timestamp to use as a base for the calculation of relative dates
+ * @return int Timestamp value
+ */
+ protected static function timeToTimestamp($time, int $now = null): int
+ {
+ // default to current time as $now
+ if (!is_int($now)) {
+ $now = time();
+ }
+
+ // convert date strings to a timestamp first
+ if (is_string($time)) {
+ $time = strtotime($time, $now);
+ }
+
+ // now make sure that we have a valid timestamp
+ if (is_int($time)) {
+ return $time;
+ } else {
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'Session::timeToTimestamp', 'argument' => '$time'],
+ 'translate' => false
+ ]);
+ }
+ }
+
+ /**
+ * Loads the session data from the session store
+ *
+ * @return void
+ */
+ protected function init()
+ {
+ // sessions that are new, written to or that have been destroyed should never be initialized
+ if ($this->tokenExpiry === null || $this->writeMode === true || $this->destroyed === true) {
+ // unexpected error that shouldn't occur
+ throw new Exception(['translate' => false]); // @codeCoverageIgnore
+ }
+
+ // make sure that the session exists
+ if ($this->sessions->store()->exists($this->tokenExpiry, $this->tokenId) !== true) {
+ throw new NotFoundException([
+ 'key' => 'session.notFound',
+ 'data' => ['token' => $this->token()],
+ 'fallback' => 'Session "' . $this->token() . '" does not exist',
+ 'translate' => false,
+ 'httpCode' => 404
+ ]);
+ }
+
+ // get the session data from the store
+ $data = $this->sessions->store()->get($this->tokenExpiry, $this->tokenId);
+
+ // verify HMAC
+ // skip if we don't have the key (only the case for moved sessions)
+ $hmac = Str::before($data, "\n");
+ $data = trim(Str::after($data, "\n"));
+ if ($this->tokenKey !== null && hash_equals(hash_hmac('sha256', $data, $this->tokenKey), $hmac) !== true) {
+ throw new LogicException([
+ 'key' => 'session.invalid',
+ 'data' => ['token' => $this->token()],
+ 'fallback' => 'Session "' . $this->token() . '" is invalid',
+ 'translate' => false,
+ 'httpCode' => 500
+ ]);
+ }
+
+ // decode the serialized data
+ try {
+ $data = unserialize($data);
+ } catch (Throwable $e) {
+ throw new LogicException([
+ 'key' => 'session.invalid',
+ 'data' => ['token' => $this->token()],
+ 'fallback' => 'Session "' . $this->token() . '" is invalid',
+ 'translate' => false,
+ 'httpCode' => 500,
+ 'previous' => $e
+ ]);
+ }
+
+ // verify start and expiry time
+ if (time() < $data['startTime'] || time() > $data['expiryTime']) {
+ throw new LogicException([
+ 'key' => 'session.invalid',
+ 'data' => ['token' => $this->token()],
+ 'fallback' => 'Session "' . $this->token() . '" is invalid',
+ 'translate' => false,
+ 'httpCode' => 500
+ ]);
+ }
+
+ // follow to the new session if there is one
+ if (isset($data['newSession'])) {
+ $this->parseToken($data['newSession'], true);
+ $this->init();
+ return;
+ }
+
+ // verify timeout
+ if (is_int($data['timeout'])) {
+ if (time() - $data['lastActivity'] > $data['timeout']) {
+ throw new LogicException([
+ 'key' => 'session.invalid',
+ 'data' => ['token' => $this->token()],
+ 'fallback' => 'Session "' . $this->token() . '" is invalid',
+ 'translate' => false,
+ 'httpCode' => 500
+ ]);
+ }
+
+ // set a new activity timestamp, but only every few minutes for better performance
+ // don't do this if another call to init() is already doing it to prevent endless loops;
+ // also don't do this for read-only sessions
+ if ($this->updatingLastActivity === false && $this->tokenKey !== null && time() - $data['lastActivity'] > $data['timeout'] / 15) {
+ $this->updatingLastActivity = true;
+ $this->prepareForWriting();
+
+ // the remaining init steps have been done by prepareForWriting()
+ $this->lastActivity = time();
+ $this->updatingLastActivity = false;
+ return;
+ }
+ }
+
+ // (re)initialize all instance variables
+ $this->startTime = $data['startTime'];
+ $this->expiryTime = $data['expiryTime'];
+ $this->duration = $data['duration'];
+ $this->timeout = $data['timeout'];
+ $this->lastActivity = $data['lastActivity'];
+ $this->renewable = $data['renewable'];
+
+ // reload data into existing object to avoid breaking memory references
+ if (is_a($this->data, 'Kirby\Session\SessionData')) {
+ $this->data()->reload($data['data']);
+ } else {
+ $this->data = new SessionData($this, $data['data']);
+ }
+ }
+
+ /**
+ * Regenerate session token, but only if there is already one
+ *
+ * @return void
+ */
+ protected function regenerateTokenIfNotNew()
+ {
+ if ($this->tokenExpiry !== null) {
+ $this->regenerateToken();
+ }
+ }
+
+ /**
+ * Automatically renews the session if possible and necessary
+ *
+ * @return void
+ */
+ protected function autoRenew()
+ {
+ // check if the session needs renewal at all
+ if ($this->needsRenewal() !== true) {
+ return;
+ }
+
+ // re-load the session and check again to make sure that no other thread
+ // already renewed the session in the meantime
+ $this->prepareForWriting();
+ if ($this->needsRenewal() === true) {
+ $this->renew();
+ }
+ }
+
+ /**
+ * Checks if the session can be renewed and if the last renewal
+ * was more than half a session duration ago
+ *
+ * @return bool
+ */
+ protected function needsRenewal(): bool
+ {
+ return $this->renewable() === true && $this->expiryTime() - time() < $this->duration() / 2;
+ }
+}
diff --git a/kirby/src/Session/SessionData.php b/kirby/src/Session/SessionData.php
new file mode 100755
index 0000000..a6aaf5e
--- /dev/null
+++ b/kirby/src/Session/SessionData.php
@@ -0,0 +1,255 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class SessionData
+{
+ protected $session;
+ protected $data;
+
+ /**
+ * Creates a new SessionData instance
+ *
+ * @codeCoverageIgnore
+ * @param \Kirby\Session\Session $session Session object this data belongs to
+ * @param array $data Currently stored session data
+ */
+ public function __construct(Session $session, array $data)
+ {
+ $this->session = $session;
+ $this->data = $data;
+ }
+
+ /**
+ * Sets one or multiple session values by key
+ *
+ * @param string|array $key The key to define or a key-value array with multiple values
+ * @param mixed $value The value for the passed key (only if one $key is passed)
+ * @return void
+ */
+ public function set($key, $value = null)
+ {
+ $this->session->ensureToken();
+ $this->session->prepareForWriting();
+
+ if (is_string($key)) {
+ $this->data[$key] = $value;
+ } elseif (is_array($key)) {
+ $this->data = array_merge($this->data, $key);
+ } else {
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'SessionData::set', 'argument' => 'key'],
+ 'translate' => false
+ ]);
+ }
+ }
+
+ /**
+ * Increments one or multiple session values by a specified amount
+ *
+ * @param string|array $key The key to increment or an array with multiple keys
+ * @param int $by Increment by which amount?
+ * @param int $max Maximum amount (value is not incremented further)
+ * @return void
+ */
+ public function increment($key, int $by = 1, $max = null)
+ {
+ if ($max !== null && !is_int($max)) {
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'SessionData::increment', 'argument' => 'max'],
+ 'translate' => false
+ ]);
+ }
+
+ if (is_string($key)) {
+ // make sure we have the correct values before getting
+ $this->session->prepareForWriting();
+
+ $value = $this->get($key, 0);
+
+ if (!is_int($value)) {
+ throw new LogicException([
+ 'key' => 'session.data.increment.nonInt',
+ 'data' => ['key' => $key],
+ 'fallback' => 'Session value "' . $key . '" is not an integer and cannot be incremented',
+ 'translate' => false
+ ]);
+ }
+
+ // increment the value, but ensure $max constraint
+ if (is_int($max) && $value + $by > $max) {
+ // set the value to $max
+ // but not if the current $value is already larger than $max
+ $value = max($value, $max);
+ } else {
+ $value += $by;
+ }
+
+ $this->set($key, $value);
+ } elseif (is_array($key)) {
+ foreach ($key as $k) {
+ $this->increment($k, $by, $max);
+ }
+ } else {
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'SessionData::increment', 'argument' => 'key'],
+ 'translate' => false
+ ]);
+ }
+ }
+
+ /**
+ * Decrements one or multiple session values by a specified amount
+ *
+ * @param string|array $key The key to decrement or an array with multiple keys
+ * @param int $by Decrement by which amount?
+ * @param int $min Minimum amount (value is not decremented further)
+ * @return void
+ */
+ public function decrement($key, int $by = 1, $min = null)
+ {
+ if ($min !== null && !is_int($min)) {
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'SessionData::decrement', 'argument' => 'min'],
+ 'translate' => false
+ ]);
+ }
+
+ if (is_string($key)) {
+ // make sure we have the correct values before getting
+ $this->session->prepareForWriting();
+
+ $value = $this->get($key, 0);
+
+ if (!is_int($value)) {
+ throw new LogicException([
+ 'key' => 'session.data.decrement.nonInt',
+ 'data' => ['key' => $key],
+ 'fallback' => 'Session value "' . $key . '" is not an integer and cannot be decremented',
+ 'translate' => false
+ ]);
+ }
+
+ // decrement the value, but ensure $min constraint
+ if (is_int($min) && $value - $by < $min) {
+ // set the value to $min
+ // but not if the current $value is already smaller than $min
+ $value = min($value, $min);
+ } else {
+ $value -= $by;
+ }
+
+ $this->set($key, $value);
+ } elseif (is_array($key)) {
+ foreach ($key as $k) {
+ $this->decrement($k, $by, $min);
+ }
+ } else {
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'SessionData::decrement', 'argument' => 'key'],
+ 'translate' => false
+ ]);
+ }
+ }
+
+ /**
+ * Returns one or all session values by key
+ *
+ * @param string|null $key The key to get or null for the entire data array
+ * @param mixed $default Optional default value to return if the key is not defined
+ * @return mixed
+ */
+ public function get($key = null, $default = null)
+ {
+ if (is_string($key)) {
+ return $this->data[$key] ?? $default;
+ } elseif ($key === null) {
+ return $this->data;
+ } else {
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'SessionData::get', 'argument' => 'key'],
+ 'translate' => false
+ ]);
+ }
+ }
+
+ /**
+ * Retrieves a value and removes it afterwards
+ *
+ * @param string $key The key to get
+ * @param mixed $default Optional default value to return if the key is not defined
+ * @return mixed
+ */
+ public function pull(string $key, $default = null)
+ {
+ // make sure we have the correct value before getting
+ // we do this here (but not in get) as we need to write anyway
+ $this->session->prepareForWriting();
+
+ $value = $this->get($key, $default);
+ $this->remove($key);
+ return $value;
+ }
+
+ /**
+ * Removes one or multiple session values by key
+ *
+ * @param string|array $key The key to remove or an array with multiple keys
+ * @return void
+ */
+ public function remove($key)
+ {
+ $this->session->prepareForWriting();
+
+ if (is_string($key)) {
+ unset($this->data[$key]);
+ } elseif (is_array($key)) {
+ foreach ($key as $k) {
+ unset($this->data[$k]);
+ }
+ } else {
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'SessionData::remove', 'argument' => 'key'],
+ 'translate' => false
+ ]);
+ }
+ }
+
+ /**
+ * Clears all session data
+ *
+ * @return void
+ */
+ public function clear()
+ {
+ $this->session->prepareForWriting();
+
+ $this->data = [];
+ }
+
+ /**
+ * Reloads the data array with the current session data
+ * Only used internally
+ *
+ * @param array $data Currently stored session data
+ * @return void
+ */
+ public function reload(array $data)
+ {
+ $this->data = $data;
+ }
+}
diff --git a/kirby/src/Session/SessionStore.php b/kirby/src/Session/SessionStore.php
new file mode 100755
index 0000000..a3a9611
--- /dev/null
+++ b/kirby/src/Session/SessionStore.php
@@ -0,0 +1,110 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+abstract class SessionStore
+{
+ /**
+ * Creates a new session ID with the given expiry time
+ *
+ * Needs to make sure that the session does not already exist
+ * and needs to reserve it by locking it exclusively.
+ *
+ * @param int $expiryTime Timestamp
+ * @return string Randomly generated session ID (without timestamp)
+ */
+ abstract public function createId(int $expiryTime): string;
+
+ /**
+ * Checks if the given session exists
+ *
+ * @param int $expiryTime Timestamp
+ * @param string $id Session ID
+ * @return bool true: session exists,
+ * false: session doesn't exist
+ */
+ abstract public function exists(int $expiryTime, string $id): bool;
+
+ /**
+ * Locks the given session exclusively
+ *
+ * Needs to throw an Exception on error.
+ *
+ * @param int $expiryTime Timestamp
+ * @param string $id Session ID
+ * @return void
+ */
+ abstract public function lock(int $expiryTime, string $id);
+
+ /**
+ * Removes all locks on the given session
+ *
+ * Needs to throw an Exception on error.
+ *
+ * @param int $expiryTime Timestamp
+ * @param string $id Session ID
+ * @return void
+ */
+ abstract public function unlock(int $expiryTime, string $id);
+
+ /**
+ * Returns the stored session data of the given session
+ *
+ * Needs to throw an Exception on error.
+ *
+ * @param int $expiryTime Timestamp
+ * @param string $id Session ID
+ * @return string
+ */
+ abstract public function get(int $expiryTime, string $id): string;
+
+ /**
+ * Stores data to the given session
+ *
+ * Needs to make sure that the session exists.
+ * Needs to throw an Exception on error.
+ *
+ * @param int $expiryTime Timestamp
+ * @param string $id Session ID
+ * @param string $data Session data to write
+ * @return void
+ */
+ abstract public function set(int $expiryTime, string $id, string $data);
+
+ /**
+ * Deletes the given session
+ *
+ * Needs to throw an Exception on error.
+ *
+ * @param int $expiryTime Timestamp
+ * @param string $id Session ID
+ * @return void
+ */
+ abstract public function destroy(int $expiryTime, string $id);
+
+ /**
+ * Deletes all expired sessions
+ *
+ * Needs to throw an Exception on error.
+ *
+ * @return void
+ */
+ abstract public function collectGarbage();
+
+ /**
+ * Securely generates a random session ID
+ *
+ * @return string Random hex string with 20 bytes
+ */
+ protected static function generateId(): string
+ {
+ return bin2hex(random_bytes(10));
+ }
+}
diff --git a/kirby/src/Session/Sessions.php b/kirby/src/Session/Sessions.php
new file mode 100755
index 0000000..d947080
--- /dev/null
+++ b/kirby/src/Session/Sessions.php
@@ -0,0 +1,288 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Sessions
+{
+ protected $store;
+ protected $mode;
+ protected $cookieName;
+
+ protected $cache = [];
+
+ /**
+ * Creates a new Sessions instance
+ *
+ * @param \Kirby\Session\SessionStore|string $store SessionStore object or a path to the storage directory (uses the FileSessionStore)
+ * @param array $options Optional additional options:
+ * - `mode`: Default token transmission mode (cookie, header or manual); defaults to `cookie`
+ * - `cookieName`: Name to use for the session cookie; defaults to `kirby_session`
+ * - `gcInterval`: How often should the garbage collector be run?; integer or `false` for never; defaults to `100`
+ */
+ public function __construct($store, array $options = [])
+ {
+ if (is_string($store)) {
+ $this->store = new FileSessionStore($store);
+ } elseif (is_a($store, 'Kirby\Session\SessionStore') === true) {
+ $this->store = $store;
+ } else {
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'Sessions::__construct', 'argument' => 'store'],
+ 'translate' => false
+ ]);
+ }
+
+ $this->mode = $options['mode'] ?? 'cookie';
+ $this->cookieName = $options['cookieName'] ?? 'kirby_session';
+ $gcInterval = $options['gcInterval'] ?? 100;
+
+ // validate options
+ if (!in_array($this->mode, ['cookie', 'header', 'manual'])) {
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'Sessions::__construct', 'argument' => '$options[\'mode\']'],
+ 'translate' => false
+ ]);
+ }
+ if (!is_string($this->cookieName)) {
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'Sessions::__construct', 'argument' => '$options[\'cookieName\']'],
+ 'translate' => false
+ ]);
+ }
+
+ // trigger automatic garbage collection with the given probability
+ if (is_int($gcInterval) && $gcInterval > 0) {
+ // convert the interval into a probability between 0 and 1
+ $gcProbability = 1 / $gcInterval;
+
+ // generate a random number
+ $random = mt_rand(1, 10000);
+
+ // $random will be below or equal $gcProbability * 10000 with a probability of $gcProbability
+ if ($random <= $gcProbability * 10000) {
+ $this->collectGarbage();
+ }
+ } elseif ($gcInterval !== false) {
+ throw new InvalidArgumentException([
+ 'data' => ['method' => 'Sessions::__construct', 'argument' => '$options[\'gcInterval\']'],
+ 'translate' => false
+ ]);
+ }
+ }
+
+ /**
+ * Creates a new empty session
+ *
+ * @param array $options Optional additional options:
+ * - `mode`: Token transmission mode (cookie or manual); defaults to default mode of the Sessions instance
+ * - `startTime`: Time the session starts being valid (date string or timestamp); defaults to `now`
+ * - `expiryTime`: Time the session expires (date string or timestamp); defaults to `+ 2 hours`
+ * - `timeout`: Activity timeout in seconds (integer or false for none); defaults to `1800` (half an hour)
+ * - `renewable`: Should it be possible to extend the expiry date?; defaults to `true`
+ * @return \Kirby\Session\Session
+ */
+ public function create(array $options = [])
+ {
+ // fall back to default mode
+ if (!isset($options['mode'])) {
+ $options['mode'] = $this->mode;
+ }
+
+ return new Session($this, null, $options);
+ }
+
+ /**
+ * Returns the specified Session object
+ *
+ * @param string $token Session token, either including or without the key
+ * @param string $mode Optional transmission mode override
+ * @return \Kirby\Session\Session
+ */
+ public function get(string $token, string $mode = null)
+ {
+ if (isset($this->cache[$token])) {
+ return $this->cache[$token];
+ }
+
+ return $this->cache[$token] = new Session($this, $token, ['mode' => $mode ?? $this->mode]);
+ }
+
+ /**
+ * Returns the current session based on the configured token transmission mode:
+ * - In `cookie` mode: Gets the session from the cookie
+ * - In `header` mode: Gets the session from the `Authorization` request header
+ * - In `manual` mode: Fails and throws an Exception
+ *
+ * @return \Kirby\Session\Session|null Either the current session or null in case there isn't one
+ */
+ public function current()
+ {
+ $token = null;
+ switch ($this->mode) {
+ case 'cookie':
+ $token = $this->tokenFromCookie();
+ break;
+ case 'header':
+ $token = $this->tokenFromHeader();
+ break;
+ case 'manual':
+ throw new LogicException([
+ 'key' => 'session.sessions.manualMode',
+ 'fallback' => 'Cannot automatically get current session in manual mode',
+ 'translate' => false,
+ 'httpCode' => 500
+ ]);
+ break;
+ default:
+ // unexpected error that shouldn't occur
+ throw new Exception(['translate' => false]); // @codeCoverageIgnore
+ }
+
+ // no token was found, no session
+ if (!is_string($token)) {
+ return null;
+ }
+
+ // token was found, try to get the session
+ try {
+ return $this->get($token);
+ } catch (Throwable $e) {
+ return null;
+ }
+ }
+
+ /**
+ * Returns the current session using the following detection order without using the configured mode:
+ * - Tries to get the session from the `Authorization` request header
+ * - Tries to get the session from the cookie
+ * - Otherwise returns null
+ *
+ * @return \Kirby\Session\Session|null Either the current session or null in case there isn't one
+ */
+ public function currentDetected()
+ {
+ $tokenFromHeader = $this->tokenFromHeader();
+ $tokenFromCookie = $this->tokenFromCookie();
+
+ // prefer header token over cookie token
+ $token = $tokenFromHeader ?? $tokenFromCookie;
+
+ // no token was found, no session
+ if (!is_string($token)) {
+ return null;
+ }
+
+ // token was found, try to get the session
+ try {
+ $mode = (is_string($tokenFromHeader))? 'header' : 'cookie';
+ return $this->get($token, $mode);
+ } catch (Throwable $e) {
+ return null;
+ }
+ }
+
+ /**
+ * Getter for the session store instance
+ * Used internally
+ *
+ * @return \Kirby\Session\SessionStore
+ */
+ public function store()
+ {
+ return $this->store;
+ }
+
+ /**
+ * Getter for the cookie name
+ * Used internally
+ *
+ * @return string
+ */
+ public function cookieName(): string
+ {
+ return $this->cookieName;
+ }
+
+ /**
+ * Deletes all expired sessions
+ *
+ * If the `gcInterval` is configured, this is done automatically
+ * on init of the Sessions object.
+ *
+ * @return void
+ */
+ public function collectGarbage()
+ {
+ $this->store()->collectGarbage();
+ }
+
+ /**
+ * Updates the instance cache with a newly created
+ * session or a session with a regenerated token
+ *
+ * @internal
+ * @param \Kirby\Session\Session $session Session instance to push to the cache
+ */
+ public function updateCache(Session $session)
+ {
+ $this->cache[$session->token()] = $session;
+ }
+
+ /**
+ * Returns the auth token from the cookie
+ *
+ * @return string|null
+ */
+ protected function tokenFromCookie()
+ {
+ $value = Cookie::get($this->cookieName());
+
+ if (is_string($value)) {
+ return $value;
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Returns the auth token from the Authorization header
+ *
+ * @return string|null
+ */
+ protected function tokenFromHeader()
+ {
+ $request = new Request();
+ $headers = $request->headers();
+
+ // check if the header exists at all
+ if (!isset($headers['Authorization'])) {
+ return null;
+ }
+
+ // check if the header uses the "Session" scheme
+ $header = $headers['Authorization'];
+ if (Str::startsWith($header, 'Session ', true) !== true) {
+ return null;
+ }
+
+ // return the part after the scheme
+ return substr($header, 8);
+ }
+}
diff --git a/kirby/src/Text/KirbyTag.php b/kirby/src/Text/KirbyTag.php
new file mode 100755
index 0000000..60634aa
--- /dev/null
+++ b/kirby/src/Text/KirbyTag.php
@@ -0,0 +1,142 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class KirbyTag
+{
+ public static $aliases = [];
+ public static $types = [];
+
+ public $attrs = [];
+ public $data = [];
+ public $options = [];
+ public $type = null;
+ public $value = null;
+
+ public function __call(string $name, array $arguments = [])
+ {
+ return $this->data[$name] ?? $this->$name;
+ }
+
+ public static function __callStatic(string $type, array $arguments = [])
+ {
+ return (new static($type, ...$arguments))->render();
+ }
+
+ public function __construct(string $type, string $value = null, array $attrs = [], array $data = [], array $options = [])
+ {
+ if (isset(static::$types[$type]) === false) {
+ if (isset(static::$aliases[$type]) === false) {
+ throw new InvalidArgumentException('Undefined tag type: ' . $type);
+ }
+
+ $type = static::$aliases[$type];
+ }
+
+ foreach ($attrs as $attrName => $attrValue) {
+ $attrName = strtolower($attrName);
+ $this->$attrName = $attrValue;
+ }
+
+ $this->attrs = $attrs;
+ $this->data = $data;
+ $this->options = $options;
+ $this->$type = $value;
+ $this->type = $type;
+ $this->value = $value;
+ }
+
+ public function __get(string $attr)
+ {
+ $attr = strtolower($attr);
+ return $this->$attr ?? null;
+ }
+
+ public function attr(string $name, $default = null)
+ {
+ $name = strtolower($name);
+ return $this->$name ?? $default;
+ }
+
+ public static function factory(...$arguments)
+ {
+ return (new static(...$arguments))->render();
+ }
+
+ /**
+ * @param string $string
+ * @param array $data
+ * @param array $options
+ * @return self
+ */
+ public static function parse(string $string, array $data = [], array $options = [])
+ {
+ // remove the brackets, extract the first attribute (the tag type)
+ $tag = trim(rtrim(ltrim($string, '('), ')'));
+ $type = trim(substr($tag, 0, strpos($tag, ':')));
+ $type = strtolower($type);
+ $attr = static::$types[$type]['attr'] ?? [];
+
+ // the type should be parsed as an attribute, so we add it here
+ // to the list of possible attributes
+ array_unshift($attr, $type);
+
+ // extract all attributes
+ $regex = sprintf('/(%s):/i', implode('|', $attr));
+ $search = preg_split($regex, $tag, false, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
+
+ // $search is now an array with alternating keys and values
+ // convert it to arrays of keys and values
+ $chunks = array_chunk($search, 2);
+ $keys = array_column($chunks, 0);
+ $values = array_map('trim', array_column($chunks, 1));
+
+ // ensure that there is a value for each key
+ // otherwise combining won't work
+ if (count($values) < count($keys)) {
+ $values[] = '';
+ }
+
+ // combine the two arrays to an associative array
+ $attributes = array_combine($keys, $values);
+
+ // the first attribute is the type attribute
+ // extract and pass its value separately
+ $value = array_shift($attributes);
+
+ return new static($type, $value, $attributes, $data, $options);
+ }
+
+ public function option(string $key, $default = null)
+ {
+ return $this->options[$key] ?? $default;
+ }
+
+ public function render(): string
+ {
+ $callback = static::$types[$this->type]['html'] ?? null;
+
+ if (is_a($callback, 'Closure') === true) {
+ return (string)$callback($this);
+ }
+
+ throw new BadMethodCallException('Invalid tag render function in tag: ' . $this->type);
+ }
+
+ public function type(): string
+ {
+ return $this->type;
+ }
+}
diff --git a/kirby/src/Text/KirbyTags.php b/kirby/src/Text/KirbyTags.php
new file mode 100755
index 0000000..08feffc
--- /dev/null
+++ b/kirby/src/Text/KirbyTags.php
@@ -0,0 +1,33 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class KirbyTags
+{
+ protected static $tagClass = 'Kirby\Text\KirbyTag';
+
+ public static function parse(string $text = null, array $data = [], array $options = []): string
+ {
+ return preg_replace_callback('!(?=[^\]])\([a-z0-9_-]+:.*?\)!is', function ($match) use ($data, $options) {
+ try {
+ return static::$tagClass::parse($match[0], $data, $options)->render();
+ } catch (Exception $e) {
+ return $match[0];
+ }
+ }, $text);
+ }
+}
diff --git a/kirby/src/Text/Markdown.php b/kirby/src/Text/Markdown.php
new file mode 100755
index 0000000..98c07c4
--- /dev/null
+++ b/kirby/src/Text/Markdown.php
@@ -0,0 +1,79 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Markdown
+{
+ /**
+ * Array with all configured options
+ * for the parser
+ *
+ * @var array
+ */
+ protected $options = [];
+
+ /**
+ * Returns default values for all
+ * available parser options
+ *
+ * @return array
+ */
+ public function defaults(): array
+ {
+ return [
+ 'extra' => false,
+ 'breaks' => true
+ ];
+ }
+
+ /**
+ * Creates a new Markdown parser
+ * with the given options
+ *
+ * @param array $options
+ */
+ public function __construct(array $options = [])
+ {
+ $this->options = array_merge($this->defaults(), $options);
+ }
+
+ /**
+ * Parses the given text and returns the HTML
+ *
+ * @param string $text
+ * @param bool $inline
+ * @return string
+ */
+ public function parse(string $text, bool $inline = false): string
+ {
+ if ($this->options['extra'] === true) {
+ $parser = new ParsedownExtra();
+ } else {
+ $parser = new Parsedown();
+ }
+
+ $parser->setBreaksEnabled($this->options['breaks']);
+
+ if ($inline === true) {
+ return @$parser->line($text);
+ } else {
+ return @$parser->text($text);
+ }
+ }
+}
diff --git a/kirby/src/Text/SmartyPants.php b/kirby/src/Text/SmartyPants.php
new file mode 100755
index 0000000..50d70cb
--- /dev/null
+++ b/kirby/src/Text/SmartyPants.php
@@ -0,0 +1,129 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class SmartyPants
+{
+ /**
+ * Array with all configured options
+ * for the parser
+ *
+ * @var array
+ */
+ protected $options = [];
+
+ /**
+ * Michelf's parser object
+ *
+ * @var SmartyPantsTypographer
+ */
+ protected $parser;
+
+ /**
+ * Returns default values for all
+ * available parser options
+ *
+ * @return array
+ */
+ public function defaults(): array
+ {
+ return [
+ 'attr' => 1,
+ 'doublequote.open' => '“',
+ 'doublequote.close' => '”',
+ 'doublequote.low' => '„',
+ 'singlequote.open' => '‘',
+ 'singlequote.close' => '’',
+ 'backtick.doublequote.open' => '“',
+ 'backtick.doublequote.close' => '”',
+ 'backtick.singlequote.open' => '‘',
+ 'backtick.singlequote.close' => '’',
+ 'emdash' => '—',
+ 'endash' => '–',
+ 'ellipsis' => '…',
+ 'space' => '(?: | | |*160;|*[aA]0;)',
+ 'space.emdash' => ' ',
+ 'space.endash' => ' ',
+ 'space.colon' => ' ',
+ 'space.semicolon' => ' ',
+ 'space.marks' => ' ',
+ 'space.frenchquote' => ' ',
+ 'space.thousand' => ' ',
+ 'space.unit' => ' ',
+ 'guillemet.leftpointing' => '«',
+ 'guillemet.rightpointing' => '»',
+ 'geresh' => '׳',
+ 'gershayim' => '״',
+ 'skip' => 'pre|code|kbd|script|style|math',
+ ];
+ }
+
+ /**
+ * Creates a new SmartyPants parser
+ * with the given options
+ *
+ * @param array $options
+ */
+ public function __construct(array $options = [])
+ {
+ $this->options = array_merge($this->defaults(), $options);
+ $this->parser = new SmartyPantsTypographer($this->options['attr']);
+
+ // configuration
+ $this->parser->smart_doublequote_open = $this->options['doublequote.open'];
+ $this->parser->smart_doublequote_close = $this->options['doublequote.close'];
+ $this->parser->smart_singlequote_open = $this->options['singlequote.open'];
+ $this->parser->smart_singlequote_close = $this->options['singlequote.close'];
+ $this->parser->backtick_doublequote_open = $this->options['backtick.doublequote.open'];
+ $this->parser->backtick_doublequote_close = $this->options['backtick.doublequote.close'];
+ $this->parser->backtick_singlequote_open = $this->options['backtick.singlequote.open'];
+ $this->parser->backtick_singlequote_close = $this->options['backtick.singlequote.close'];
+ $this->parser->em_dash = $this->options['emdash'];
+ $this->parser->en_dash = $this->options['endash'];
+ $this->parser->ellipsis = $this->options['ellipsis'];
+ $this->parser->tags_to_skip = $this->options['skip'];
+ $this->parser->space_emdash = $this->options['space.emdash'];
+ $this->parser->space_endash = $this->options['space.endash'];
+ $this->parser->space_colon = $this->options['space.colon'];
+ $this->parser->space_semicolon = $this->options['space.semicolon'];
+ $this->parser->space_marks = $this->options['space.marks'];
+ $this->parser->space_frenchquote = $this->options['space.frenchquote'];
+ $this->parser->space_thousand = $this->options['space.thousand'];
+ $this->parser->space_unit = $this->options['space.unit'];
+ $this->parser->doublequote_low = $this->options['doublequote.low'];
+ $this->parser->guillemet_leftpointing = $this->options['guillemet.leftpointing'];
+ $this->parser->guillemet_rightpointing = $this->options['guillemet.rightpointing'];
+ $this->parser->geresh = $this->options['geresh'];
+ $this->parser->gershayim = $this->options['gershayim'];
+ $this->parser->space = $this->options['space'];
+ }
+
+ /**
+ * Parses the given text
+ *
+ * @param string $text
+ * @return string
+ */
+ public function parse(string $text): string
+ {
+ // prepare the text
+ $text = str_replace('"', '"', $text);
+
+ // parse the text
+ return $this->parser->transform($text);
+ }
+}
diff --git a/kirby/src/Toolkit/A.php b/kirby/src/Toolkit/A.php
new file mode 100755
index 0000000..9d03da5
--- /dev/null
+++ b/kirby/src/Toolkit/A.php
@@ -0,0 +1,605 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class A
+{
+ /**
+ * Appends the given array
+ *
+ * @param array $array
+ * @param array $append
+ * @return array
+ */
+ public static function append(array $array, array $append): array
+ {
+ return $array + $append;
+ }
+
+ /**
+ * Gets an element of an array by key
+ *
+ *
+ * $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'];
+ *
+ *
+ * @param array $array The source array
+ * @param mixed $key The key to look for
+ * @param mixed $default Optional default value, which should be
+ * returned if no element has been found
+ * @return mixed
+ */
+ public static function get($array, $key, $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];
+ }
+
+ // support dot notation
+ if (strpos($key, '.') !== false) {
+ $keys = explode('.', $key);
+ $firstKey = array_shift($keys);
+
+ if (isset($array[$firstKey]) === false) {
+ $currentKey = $firstKey;
+
+ while ($innerKey = array_shift($keys)) {
+ $currentKey = $currentKey . '.' . $innerKey;
+
+ if (isset($array[$currentKey]) === true && is_array($array[$currentKey])) {
+ return static::get($array[$currentKey], implode('.', $keys), $default);
+ }
+ }
+
+ return $default;
+ }
+
+ if (is_array($array[$firstKey]) === true) {
+ return static::get($array[$firstKey], implode('.', $keys), $default);
+ }
+
+ return $default;
+ }
+
+ return $default;
+ }
+
+ /**
+ * @param mixed $value
+ * @param mixed $separator
+ * @return string
+ */
+ public static function join($value, $separator = ', ')
+ {
+ if (is_string($value) === true) {
+ return $value;
+ }
+ return implode($separator, $value);
+ }
+
+ const MERGE_OVERWRITE = 0;
+ const MERGE_APPEND = 1;
+ const MERGE_REPLACE = 2;
+
+ /**
+ * Merges arrays recursively
+ *
+ * @param array $array1
+ * @param array $array2
+ * @param bool $mode Behavior for elements with numeric keys;
+ * A::MERGE_APPEND: elements are appended, keys are reset;
+ * A::MERGE_OVERWRITE: elements are overwritten, keys are preserved
+ * A::MERGE_REPLACE: non-associative arrays are completely replaced
+ * @return array
+ */
+ public static function merge($array1, $array2, $mode = A::MERGE_APPEND)
+ {
+ $merged = $array1;
+
+ if (static::isAssociative($array1) === false && $mode === static::MERGE_REPLACE) {
+ return $array2;
+ }
+
+ foreach ($array2 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, []);
+ }
+
+ return $merged;
+ }
+
+ /**
+ * Plucks a single column from an array
+ *
+ *
+ * $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'];
+ *
+ *
+ * @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)
+ {
+ $output = [];
+ foreach ($array as $a) {
+ if (isset($a[$key]) === true) {
+ $output[] = $a[$key];
+ }
+ }
+
+ return $output;
+ }
+
+ /**
+ * Prepends the given array
+ *
+ * @param array $array
+ * @param array $prepend
+ * @return array
+ */
+ public static function prepend(array $array, array $prepend): array
+ {
+ return $prepend + $array;
+ }
+
+ /**
+ * Shuffles an array and keeps the keys
+ *
+ *
+ * $array = [
+ * 'cat' => 'miao',
+ * 'dog' => 'wuff',
+ * 'bird' => 'tweet'
+ * ];
+ *
+ * $shuffled = A::shuffle($array);
+ * // output: [
+ * // 'dog' => 'wuff',
+ * // 'cat' => 'miao',
+ * // 'bird' => 'tweet'
+ * // ];
+ *
+ *
+ * @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 the first element of an array
+ *
+ *
+ * $array = [
+ * 'cat' => 'miao',
+ * 'dog' => 'wuff',
+ * 'bird' => 'tweet'
+ * ];
+ *
+ * $first = A::first($array);
+ * // first: 'miao'
+ *
+ *
+ * @param array $array The source array
+ * @return mixed The first element
+ */
+ public static function first(array $array)
+ {
+ return array_shift($array);
+ }
+
+ /**
+ * Returns the last element of an array
+ *
+ *
+ * $array = [
+ * 'cat' => 'miao',
+ * 'dog' => 'wuff',
+ * 'bird' => 'tweet'
+ * ];
+ *
+ * $last = A::last($array);
+ * // last: 'tweet'
+ *
+ *
+ * @param array $array The source array
+ * @return mixed The last element
+ */
+ public static function last(array $array)
+ {
+ return array_pop($array);
+ }
+
+ /**
+ * Fills an array up with additional elements to certain amount.
+ *
+ *
+ * $array = [
+ * 'cat' => 'miao',
+ * 'dog' => 'wuff',
+ * 'bird' => 'tweet'
+ * ];
+ *
+ * $result = A::fill($array, 5, 'elephant');
+ *
+ * // result: [
+ * // 'cat',
+ * // 'dog',
+ * // 'bird',
+ * // 'elephant',
+ * // 'elephant',
+ * // ];
+ *
+ *
+ * @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
+ * @return array The filled-up result array
+ */
+ public static function fill(array $array, int $limit, $fill = 'placeholder'): array
+ {
+ if (count($array) < $limit) {
+ $diff = $limit - count($array);
+ for ($x = 0; $x < $diff; $x++) {
+ $array[] = $fill;
+ }
+ }
+ return $array;
+ }
+
+ /**
+ * Move an array item to a new index
+ *
+ * @param array $array
+ * @param int $from
+ * @param int $to
+ * @return array
+ */
+ 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;
+ }
+
+ /**
+ * Checks for missing elements in an array
+ *
+ * This is very handy to check for missing
+ * user values in a request for example.
+ *
+ *
+ * $array = [
+ * 'cat' => 'miao',
+ * 'dog' => 'wuff',
+ * 'bird' => 'tweet'
+ * ];
+ *
+ * $required = ['cat', 'elephant'];
+ *
+ * $missng = A::missing($array, $required);
+ * // missing: [
+ * // 'elephant'
+ * // ];
+ *
+ *
+ * @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
+ {
+ $missing = [];
+ foreach ($required as $r) {
+ if (isset($array[$r]) === false) {
+ $missing[] = $r;
+ }
+ }
+ return $missing;
+ }
+
+ /**
+ * Sorts a multi-dimensional array by a certain column
+ *
+ *
+ * $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
+ * // )
+ * // )
+ *
+ *
+ *
+ * @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', $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;
+ }
+
+ /**
+ * Checks wether an array is associative or not
+ *
+ *
+ * $array = ['a', 'b', 'c'];
+ *
+ * A::isAssociative($array);
+ * // returns: false
+ *
+ * $array = ['a' => 'a', 'b' => 'b', 'c' => 'c'];
+ *
+ * A::isAssociative($array);
+ * // returns: true
+ *
+ *
+ * @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(null, array_keys($array))) === false;
+ }
+
+ /**
+ * Returns the average value of an array
+ *
+ * @param array $array The source array
+ * @param int $decimals The number of decimals to return
+ * @return float The average value
+ */
+ public static function average(array $array, int $decimals = 0): float
+ {
+ return round((array_sum($array) / sizeof($array)), $decimals);
+ }
+
+ /**
+ * Merges arrays recursively
+ *
+ *
+ * $defaults = [
+ * 'username' => 'admin',
+ * 'password' => 'admin',
+ * ];
+ *
+ * $options = A::extend($defaults, ['password' => 'super-secret']);
+ * // returns: [
+ * // 'username' => 'admin',
+ * // 'password' => 'super-secret'
+ * // ];
+ *
+ *
+ * @param array ...$arrays
+ * @return array
+ */
+ public static function extend(...$arrays): array
+ {
+ return array_merge_recursive(...$arrays);
+ }
+
+ /**
+ * Update an array with a second array
+ * The second array can contain callbacks as values,
+ * which will get the original values as argument
+ *
+ *
+ * $user = [
+ * 'username' => 'homer',
+ * 'email' => 'homer@simpsons.com'
+ * ];
+ *
+ * // simple updates
+ * A::update($user, [
+ * 'username' => 'homer j. simpson'
+ * ]);
+ *
+ * // with callback
+ * A::update($user, [
+ * 'username' => function ($username) {
+ * return $username . ' j. simpson'
+ * }
+ * ]);
+ *
+ *
+ * @param array $array
+ * @param array $update
+ * @return array
+ */
+ public static function update(array $array, array $update): array
+ {
+ foreach ($update as $key => $value) {
+ if (is_a($value, 'Closure') === true) {
+ $array[$key] = call_user_func($value, static::get($array, $key));
+ } else {
+ $array[$key] = $value;
+ }
+ }
+
+ return $array;
+ }
+
+ /**
+ * Wraps the given value in an array
+ * if it's not an array yet.
+ *
+ * @param mixed|null $array
+ * @return array
+ */
+ public static function wrap($array = null): array
+ {
+ if ($array === null) {
+ return [];
+ } elseif (is_array($array) === false) {
+ return [$array];
+ } else {
+ return $array;
+ }
+ }
+}
diff --git a/kirby/src/Toolkit/Collection.php b/kirby/src/Toolkit/Collection.php
new file mode 100755
index 0000000..b0787f5
--- /dev/null
+++ b/kirby/src/Toolkit/Collection.php
@@ -0,0 +1,1296 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Collection extends Iterator implements Countable
+{
+ /**
+ * All registered collection filters
+ *
+ * @var array
+ */
+ public static $filters = [];
+
+ /**
+ * Pagination object
+ * @var Pagination
+ */
+ protected $pagination;
+
+ /**
+ * Magic getter function
+ *
+ * @param string $key
+ * @param mixed $arguments
+ * @return mixed
+ */
+ public function __call(string $key, $arguments)
+ {
+ return $this->__get($key);
+ }
+
+ /**
+ * Constructor
+ *
+ * @param array $data
+ */
+ public function __construct(array $data = [])
+ {
+ $this->set($data);
+ }
+
+ /**
+ * Improve var_dump() output
+ *
+ * @return array
+ */
+ public function __debugInfo(): array
+ {
+ return $this->keys();
+ }
+
+ /**
+ * Low-level getter for elements
+ *
+ * @param mixed $key
+ * @return mixed
+ */
+ public function __get($key)
+ {
+ if (isset($this->data[$key])) {
+ return $this->data[$key];
+ }
+
+ return $this->data[strtolower($key)] ?? null;
+ }
+
+ /**
+ * Low-level setter for elements
+ *
+ * @param string $key string or array
+ * @param mixed $value
+ */
+ public function __set(string $key, $value)
+ {
+ $this->data[strtolower($key)] = $value;
+ return $this;
+ }
+
+ /**
+ * Makes it possible to echo the entire object
+ *
+ * @return string
+ */
+ public function __toString(): string
+ {
+ return $this->toString();
+ }
+
+ /**
+ * Low-level element remover
+ *
+ * @param mixed $key the name of the key
+ */
+ public function __unset($key)
+ {
+ unset($this->data[$key]);
+ }
+
+ /**
+ * Appends an element
+ *
+ * @param mixed $key
+ * @param mixed $item
+ * @return \Kirby\Toolkit\Collection
+ */
+ public function append(...$args)
+ {
+ if (count($args) === 1) {
+ $this->data[] = $args[0];
+ } elseif (count($args) > 1) {
+ $this->set($args[0], $args[1]);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Creates chunks of the same size.
+ * The last chunk may be smaller
+ *
+ * @param int $size Number of elements per chunk
+ * @return \Kirby\Toolkit\Collection A new collection with an element for each chunk and
+ * a sub collection in each chunk
+ */
+ public function chunk(int $size)
+ {
+ // create a multidimensional array that is chunked with the given
+ // chunk size keep keys of the elements
+ $chunks = array_chunk($this->data, $size, true);
+
+ // convert each chunk to a subcollection
+ $collection = [];
+
+ foreach ($chunks as $items) {
+ // we clone $this instead of creating a new object because
+ // different objects may have different constructors
+ $clone = clone $this;
+ $clone->data = $items;
+
+ $collection[] = $clone;
+ }
+
+ // convert the array of chunks to a collection
+ $result = clone $this;
+ $result->data = $collection;
+
+ return $result;
+ }
+
+ /**
+ * Returns a cloned instance of the collection
+ *
+ * @return self
+ */
+ public function clone()
+ {
+ return clone $this;
+ }
+
+ /**
+ * Getter and setter for the data
+ *
+ * @param array $data
+ * @return array|Collection
+ */
+ public function data(array $data = null)
+ {
+ if ($data === null) {
+ return $this->data;
+ }
+
+ // clear all previous data
+ $this->data = [];
+
+ // overwrite the data array
+ $this->data = $data;
+
+ return $this;
+ }
+
+ /**
+ * Clone and remove all elements from the collection
+ *
+ * @return \Kirby\Toolkit\Collection
+ */
+ public function empty()
+ {
+ $collection = clone $this;
+ $collection->data = [];
+
+ return $collection;
+ }
+
+ /**
+ * Adds all elements to the collection
+ *
+ * @param mixed $items
+ * @return self
+ */
+ public function extend($items)
+ {
+ $collection = clone $this;
+ return $collection->set($items);
+ }
+
+ /**
+ * Filters elements by a custom
+ * filter function or an array of filters
+ *
+ * @param Closure $filter
+ * @return self
+ */
+ public function filter($filter)
+ {
+ if (is_callable($filter) === true) {
+ $collection = clone $this;
+ $collection->data = array_filter($this->data, $filter);
+
+ return $collection;
+ } elseif (is_array($filter) === true) {
+ $collection = $this;
+
+ foreach ($filter as $arguments) {
+ $collection = $collection->filterBy(...$arguments);
+ }
+
+ return $collection;
+ }
+
+ throw new Exception('The filter method needs either an array of filterBy rules or a closure function to be passed as parameter.');
+ }
+
+ /**
+ * Filters elements by one of the
+ * predefined filter methods.
+ *
+ * @param string $field
+ * @param array ...$args
+ * @return self
+ */
+ public function filterBy(string $field, ...$args)
+ {
+ $operator = '==';
+ $test = $args[0] ?? null;
+ $split = $args[1] ?? false;
+
+ if (is_string($test) === true && isset(static::$filters[$test]) === true) {
+ $operator = $test;
+ $test = $args[1] ?? null;
+ $split = $args[2] ?? false;
+ }
+
+ if (is_object($test) === true && method_exists($test, '__toString') === true) {
+ $test = (string)$test;
+ }
+
+ // get the filter from the filters array
+ $filter = static::$filters[$operator] ?? null;
+
+ // return an unfiltered list if the filter does not exist
+ if ($filter === null) {
+ return $this;
+ }
+
+ if (is_array($filter) === true) {
+ $collection = clone $this;
+ $validator = $filter['validator'];
+ $strict = $filter['strict'] ?? true;
+ $method = $strict ? 'filterMatchesAll' : 'filterMatchesAny';
+
+ foreach ($collection->data as $key => $item) {
+ $value = $collection->getAttribute($item, $field, $split);
+
+ if ($split !== false) {
+ if ($this->$method($validator, $value, $test) === false) {
+ unset($collection->data[$key]);
+ }
+ } elseif ($validator($value, $test) === false) {
+ unset($collection->data[$key]);
+ }
+ }
+
+ return $collection;
+ }
+
+ return $filter(clone $this, $field, $test, $split);
+ }
+
+ protected function filterMatchesAny($validator, $values, $test): bool
+ {
+ foreach ($values as $value) {
+ if ($validator($value, $test) !== false) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected function filterMatchesAll($validator, $values, $test): bool
+ {
+ foreach ($values as $value) {
+ if ($validator($value, $test) === false) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ protected function filterMatchesNone($validator, $values, $test): bool
+ {
+ $matches = 0;
+
+ foreach ($values as $value) {
+ if ($validator($value, $test) !== false) {
+ $matches++;
+ }
+ }
+
+ return $matches === 0;
+ }
+
+ /**
+ * Find one or multiple elements by id
+ *
+ * @param string ...$keys
+ * @return mixed
+ */
+ public function find(...$keys)
+ {
+ if (count($keys) === 1) {
+ if (is_array($keys[0]) === true) {
+ $keys = $keys[0];
+ } else {
+ return $this->findByKey($keys[0]);
+ }
+ }
+
+ $result = [];
+
+ foreach ($keys as $key) {
+ if ($item = $this->findByKey($key)) {
+ if (is_object($item) && method_exists($item, 'id') === true) {
+ $key = $item->id();
+ }
+ $result[$key] = $item;
+ }
+ }
+
+ $collection = clone $this;
+ $collection->data = $result;
+ return $collection;
+ }
+
+ /**
+ * Find a single element by an attribute and its value
+ *
+ * @param string $attribute
+ * @param mixed $value
+ * @return mixed|null
+ */
+ public function findBy(string $attribute, $value)
+ {
+ foreach ($this->data as $item) {
+ if ($this->getAttribute($item, $attribute) == $value) {
+ return $item;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Find a single element by key (id)
+ *
+ * @param string $key
+ * @return mixed
+ */
+ public function findByKey(string $key)
+ {
+ return $this->get($key);
+ }
+
+ /**
+ * Returns the first element
+ *
+ * @return mixed
+ */
+ public function first()
+ {
+ $array = $this->data;
+ return array_shift($array);
+ }
+
+ /**
+ * Returns the elements in reverse order
+ *
+ * @return \Kirby\Toolkit\Collection
+ */
+ public function flip()
+ {
+ $collection = clone $this;
+ $collection->data = array_reverse($this->data, true);
+ return $collection;
+ }
+
+ /**
+ * Getter
+ *
+ * @param mixed $key
+ * @param mixed $default
+ * @return mixed
+ */
+ public function get($key, $default = null)
+ {
+ return $this->__get($key) ?? $default;
+ }
+
+ /**
+ * Extracts an attribute value from the given element
+ * in the collection. This is useful if elements in the collection
+ * might be objects, arrays or anything else and you need to
+ * get the value independently from that. We use it for filterBy.
+ *
+ * @param array|object $item
+ * @param string $attribute
+ * @param bool $split
+ * @param mixed $related
+ * @return mixed
+ */
+ public function getAttribute($item, string $attribute, $split = false, $related = null)
+ {
+ $value = $this->{'getAttributeFrom' . gettype($item)}($item, $attribute);
+
+ if ($split !== false) {
+ return Str::split($value, $split === true ? ',' : $split);
+ }
+
+ if ($related !== null) {
+ return Str::toType((string)$value, $related);
+ }
+
+ return $value;
+ }
+
+ /**
+ * @param array $array
+ * @param string $attribute
+ * @return mixed
+ */
+ protected function getAttributeFromArray(array $array, string $attribute)
+ {
+ return $array[$attribute] ?? null;
+ }
+
+ /**
+ * @param object $object
+ * @param string $attribute
+ * @return void
+ */
+ protected function getAttributeFromObject($object, string $attribute)
+ {
+ return $object->{$attribute}();
+ }
+
+ /**
+ * Groups the elements by a given callback
+ *
+ * @param Closure $callback
+ * @return self A new collection with an element for each group and a subcollection in each group
+ */
+ public function group(Closure $callback)
+ {
+ $groups = [];
+
+ foreach ($this->data as $key => $item) {
+
+ // get the value to group by
+ $value = $callback($item);
+
+ // make sure that there's always a proper value to group by
+ if (!$value) {
+ throw new Exception('Invalid grouping value for key: ' . $key);
+ }
+
+ // make sure we have a proper key for each group
+ if (is_array($value) === true) {
+ throw new Exception('You cannot group by arrays or objects');
+ } elseif (is_object($value) === true) {
+ if (method_exists($value, '__toString') === false) {
+ throw new Exception('You cannot group by arrays or objects');
+ } else {
+ $value = (string)$value;
+ }
+ }
+
+ if (isset($groups[$value]) === false) {
+ // create a new entry for the group if it does not exist yet
+ $groups[$value] = new static([$key => $item]);
+ } else {
+ // add the element to an existing group
+ $groups[$value]->set($key, $item);
+ }
+ }
+
+ return new Collection($groups);
+ }
+
+ /**
+ * Groups the elements by a given field
+ *
+ * @param string $field
+ * @param bool $i
+ * @return \Kirby\Toolkit\Collection A new collection with an element for each group and a subcollection in each group
+ */
+ public function groupBy($field, bool $i = true)
+ {
+ if (is_string($field) === false) {
+ throw new Exception('Cannot group by non-string values. Did you mean to call group()?');
+ }
+
+ return $this->group(function ($item) use ($field, $i) {
+ $value = $this->getAttribute($item, $field);
+
+ // ignore upper/lowercase for group names
+ return $i === true ? Str::lower($value) : $value;
+ });
+ }
+
+ /**
+ * Returns a Collection with the intersection of the given elements
+ * @since 3.3.0
+ *
+ * @param \Kirby\Toolkit\Collection $other
+ * @return \Kirby\Toolkit\Collection
+ */
+ public function intersection($other)
+ {
+ return $other->find($this->keys());
+ }
+
+ /**
+ * Checks if there is an intersection between the given collection and this collection
+ * @since 3.3.0
+ *
+ * @param \Kirby\Toolkit\Collection $other
+ * @return bool
+ */
+ public function intersects($other): bool
+ {
+ foreach ($this->keys() as $key) {
+ if ($other->has($key)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Checks if the number of elements is zero
+ *
+ * @return bool
+ */
+ public function isEmpty(): bool
+ {
+ return $this->count() === 0;
+ }
+
+ /**
+ * Checks if the number of elements is even
+ *
+ * @return bool
+ */
+ public function isEven(): bool
+ {
+ return $this->count() % 2 === 0;
+ }
+
+ /**
+ * Checks if the number of elements is more than zero
+ *
+ * @return bool
+ */
+ public function isNotEmpty(): bool
+ {
+ return $this->count() > 0;
+ }
+
+ /**
+ * Checks if the number of elements is odd
+ *
+ * @return bool
+ */
+ public function isOdd(): bool
+ {
+ return $this->count() % 2 !== 0;
+ }
+
+ /**
+ * Returns the last element
+ *
+ * @return mixed
+ */
+ public function last()
+ {
+ $array = $this->data;
+ return array_pop($array);
+ }
+
+ /**
+ * Returns a new object with a limited number of elements
+ *
+ * @param int $limit The number of elements to return
+ * @return \Kirby\Toolkit\Collection
+ */
+ public function limit(int $limit)
+ {
+ return $this->slice(0, $limit);
+ }
+
+ /**
+ * Map a function to each element
+ *
+ * @param callable $callback
+ * @return \Kirby\Toolkit\Collection
+ */
+ public function map(callable $callback)
+ {
+ $this->data = array_map($callback, $this->data);
+ return $this;
+ }
+
+ /**
+ * Returns the nth element from the collection
+ *
+ * @param int $n
+ * @return mixed
+ */
+ public function nth(int $n)
+ {
+ return array_values($this->data)[$n] ?? null;
+ }
+
+ /**
+ * Returns a Collection without the given element(s)
+ *
+ * @param string ...$keys any number of keys, passed as individual arguments
+ * @return \Kirby\Toolkit\Collection
+ */
+ public function not(...$keys)
+ {
+ $collection = clone $this;
+ foreach ($keys as $key) {
+ unset($collection->data[$key]);
+ }
+ return $collection;
+ }
+
+ /**
+ * Returns a new object starting from the given offset
+ *
+ * @param int $offset The index to start from
+ * @return \Kirby\Toolkit\Collection
+ */
+ public function offset(int $offset)
+ {
+ return $this->slice($offset);
+ }
+
+ /**
+ * Add pagination
+ *
+ * @param array ...$arguments
+ * @return \Kirby\Toolkit\Collection a sliced set of data
+ */
+ public function paginate(...$arguments)
+ {
+ $this->pagination = Pagination::for($this, ...$arguments);
+
+ // slice and clone the collection according to the pagination
+ return $this->slice($this->pagination->offset(), $this->pagination->limit());
+ }
+
+ /**
+ * Get the previously added pagination object
+ *
+ * @return \Kirby\Toolkit\Pagination|null
+ */
+ public function pagination()
+ {
+ return $this->pagination;
+ }
+
+ /**
+ * Extracts all values for a single field into
+ * a new array
+ *
+ * @param string $field
+ * @param string $split
+ * @param bool $unique
+ * @return array
+ */
+ public function pluck(string $field, string $split = null, bool $unique = false): array
+ {
+ $result = [];
+
+ foreach ($this->data as $item) {
+ $row = $this->getAttribute($item, $field);
+
+ if ($split !== null) {
+ $result = array_merge($result, Str::split($row, $split));
+ } else {
+ $result[] = $row;
+ }
+ }
+
+ if ($unique === true) {
+ $result = array_unique($result);
+ }
+
+ return array_values($result);
+ }
+
+ /**
+ * Prepends an element to the data array
+ *
+ * @param mixed $key
+ * @param mixed $item
+ * @return self
+ */
+ public function prepend(...$args)
+ {
+ if (count($args) === 1) {
+ array_unshift($this->data, $args[0]);
+ } elseif (count($args) > 1) {
+ $data = $this->data;
+ $this->data = [];
+ $this->set($args[0], $args[1]);
+ $this->data += $data;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Runs a combination of filterBy, sortBy, not
+ * offset, limit and paginate on the collection.
+ * Any part of the query is optional.
+ *
+ * @param array $arguments
+ * @return self
+ */
+ public function query(array $arguments = [])
+ {
+ $result = clone $this;
+
+ if (isset($arguments['not']) === true) {
+ $result = $result->not(...$arguments['not']);
+ }
+
+ if (isset($arguments['filterBy']) === true) {
+ foreach ($arguments['filterBy'] as $filter) {
+ if (isset($filter['field']) === true && isset($filter['value']) === true) {
+ $result = $result->filterBy($filter['field'], $filter['operator'] ?? '==', $filter['value']);
+ }
+ }
+ }
+
+ if (isset($arguments['offset']) === true) {
+ $result = $result->offset($arguments['offset']);
+ }
+
+ if (isset($arguments['limit']) === true) {
+ $result = $result->limit($arguments['limit']);
+ }
+
+ if (isset($arguments['sortBy']) === true) {
+ if (is_array($arguments['sortBy'])) {
+ $sort = explode(' ', implode(' ', $arguments['sortBy']));
+ } else {
+ $sort = explode(' ', $arguments['sortBy']);
+ }
+ $result = $result->sortBy(...$sort);
+ }
+
+ if (isset($arguments['paginate']) === true) {
+ $result = $result->paginate($arguments['paginate']);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Removes an element from the array by key
+ *
+ * @param mixed $key the name of the key
+ */
+ public function remove($key)
+ {
+ $this->__unset($key);
+ return $this;
+ }
+
+ /**
+ * Adds a new element to the collection
+ *
+ * @param mixed $key string or array
+ * @param mixed $value
+ * @return self
+ */
+ public function set($key, $value = null)
+ {
+ if (is_array($key)) {
+ foreach ($key as $k => $v) {
+ $this->__set($k, $v);
+ }
+ } else {
+ $this->__set($key, $value);
+ }
+ return $this;
+ }
+
+ /**
+ * Shuffle all elements
+ *
+ * @return \Kirby\Toolkit\Collection
+ */
+ public function shuffle()
+ {
+ $data = $this->data;
+ $keys = $this->keys();
+ shuffle($keys);
+
+ $collection = clone $this;
+ $collection->data = [];
+
+ foreach ($keys as $key) {
+ $collection->data[$key] = $data[$key];
+ }
+
+ return $collection;
+ }
+
+ /**
+ * Returns a slice of the object
+ *
+ * @param int $offset The optional index to start the slice from
+ * @param int $limit The optional number of elements to return
+ * @return \Kirby\Toolkit\Collection
+ */
+ public function slice(int $offset = 0, int $limit = null)
+ {
+ if ($offset === 0 && $limit === null) {
+ return $this;
+ }
+
+ $collection = clone $this;
+ $collection->data = array_slice($this->data, $offset, $limit);
+ return $collection;
+ }
+
+ /**
+ * Get sort arguments from a string
+ *
+ * @param string $sortBy
+ * @return array
+ */
+ public static function sortArgs(string $sortBy): array
+ {
+ $sortArgs = Str::split($sortBy, ' ');
+
+ // fill in PHP constants
+ array_walk($sortArgs, function (string &$value) {
+ if (Str::startsWith($value, 'SORT_') === true && defined($value) === true) {
+ $value = constant($value);
+ }
+ });
+
+ return $sortArgs;
+ }
+
+ /**
+ * Sorts the elements by any number of fields
+ *
+ * @param string|callable $field Field name or value callback to sort by
+ * @param string $direction asc or desc
+ * @param int $method The sort flag, SORT_REGULAR, SORT_NUMERIC etc.
+ * @return Collection
+ */
+ public function sortBy()
+ {
+ // there is no need to sort empty collections
+ if (empty($this->data) === true) {
+ return $this;
+ }
+
+ $args = func_get_args();
+ $array = $this->data;
+ $collection = $this->clone();
+
+ // loop through all method arguments and find sets of fields to sort by
+ $fields = [];
+
+ foreach ($args as $arg) {
+
+ // get the index of the latest field array inside the $fields array
+ $currentField = $fields ? count($fields) - 1 : 0;
+
+ // detect the type of argument
+ // sorting direction
+ $argLower = is_string($arg) ? strtolower($arg) : null;
+
+ if ($arg === SORT_ASC || $argLower === 'asc') {
+ $fields[$currentField]['direction'] = SORT_ASC;
+ } elseif ($arg === SORT_DESC || $argLower === 'desc') {
+ $fields[$currentField]['direction'] = SORT_DESC;
+
+ // other string: the field name
+ } elseif (is_string($arg) === true) {
+ $values = [];
+
+ foreach ($array as $key => $value) {
+ $value = $collection->getAttribute($value, $arg);
+
+ // make sure that we return something sortable
+ // but don't convert other scalars (especially numbers) to strings!
+ $values[$key] = is_scalar($value) === true ? $value : (string)$value;
+ }
+
+ $fields[] = ['field' => $arg, 'values' => $values];
+
+ // callable: custom field values
+ } elseif (is_callable($arg) === true) {
+ $values = [];
+
+ foreach ($array as $key => $value) {
+ $value = $arg($value);
+
+ // make sure that we return something sortable
+ // but don't convert other scalars (especially numbers) to strings!
+ $values[$key] = is_scalar($value) === true ? $value : (string)$value;
+ }
+
+ $fields[] = ['field' => null, 'values' => $values];
+
+ // flags
+ } else {
+ $fields[$currentField]['flags'] = $arg;
+ }
+ }
+
+ // build the multisort params in the right order
+ $params = [];
+
+ foreach ($fields as $field) {
+ $params[] = $field['values'] ?? [];
+ $params[] = $field['direction'] ?? SORT_ASC;
+ $params[] = $field['flags'] ?? SORT_NATURAL | SORT_FLAG_CASE;
+ }
+
+ // check what kind of collection items we have; only check for the first
+ // item for better performance (we assume that all collection items are
+ // of the same type)
+ $firstItem = $collection->first();
+ if (is_object($firstItem) === true) {
+ // avoid the "Nesting level too deep - recursive dependency?" error
+ // when PHP tries to sort by the objects directly (in case all other
+ // fields are 100 % equal for some elements)
+ if (method_exists($firstItem, '__toString') === true) {
+ // PHP can easily convert the objects to strings, so it should
+ // compare them as strings instead of as objects to avoid the recursion
+ $params[] = &$array;
+ $params[] = SORT_STRING;
+ } else {
+ // we can't convert the objects to strings, so we need a fallback:
+ // custom fictional field that is guaranteed to have a unique value
+ // for each item; WARNING: may lead to slightly wrong sorting results
+ // and is therefore only used as a fallback if we don't have another way
+ $params[] = range(1, count($array));
+ $params[] = SORT_ASC;
+ $params[] = SORT_NUMERIC;
+
+ $params[] = &$array;
+ }
+ } else {
+ // collection items are scalar or array; no correction necessary
+ $params[] = &$array;
+ }
+
+ // array_multisort receives $params as separate params
+ array_multisort(...$params);
+
+ // $array has been overwritten by array_multisort
+ $collection->data = $array;
+ return $collection;
+ }
+
+ /**
+ * Converts the object into an array
+ *
+ * @param Closure $map
+ * @return array
+ */
+ public function toArray(Closure $map = null): array
+ {
+ if ($map !== null) {
+ return array_map($map, $this->data);
+ }
+
+ return $this->data;
+ }
+
+ /**
+ * Converts the object into a JSON string
+ *
+ * @return string
+ */
+ public function toJson(): string
+ {
+ return json_encode($this->toArray());
+ }
+
+ /**
+ * Convertes the object to a string
+ *
+ * @return string
+ */
+ public function toString(): string
+ {
+ return implode('
+ *
+ * $dirname = F::dirname('/var/www/test.txt');
+ * // dirname is /var/www
+ *
+ *
+ *
+ * @param string $file The path
+ * @return string
+ */
+ public static function dirname(string $file): string
+ {
+ return dirname($file);
+ }
+
+ /**
+ * Checks if the file exists on disk
+ *
+ * @param string $file
+ * @param string $in
+ * @return bool
+ */
+ public static function exists(string $file, string $in = null): bool
+ {
+ try {
+ static::realpath($file, $in);
+ return true;
+ } catch (Exception $e) {
+ return false;
+ }
+ }
+
+ /**
+ * Gets the extension of a file
+ *
+ * @param string $file The filename or path
+ * @param string $extension Set an optional extension to overwrite the current one
+ * @return string
+ */
+ public static function extension(string $file = null, string $extension = null): string
+ {
+ // overwrite the current extension
+ if ($extension !== null) {
+ return static::name($file) . '.' . $extension;
+ }
+
+ // return the current extension
+ return Str::lower(pathinfo($file, PATHINFO_EXTENSION));
+ }
+
+ /**
+ * Converts a file extension to a mime type
+ *
+ * @param string $extension
+ * @return string|false
+ */
+ public static function extensionToMime(string $extension)
+ {
+ return Mime::fromExtension($extension);
+ }
+
+ /**
+ * Returns the file type for a passed extension
+ *
+ * @param string $extension
+ * @return string|false
+ */
+ public static function extensionToType(string $extension)
+ {
+ foreach (static::$types as $type => $extensions) {
+ if (in_array($extension, $extensions) === true) {
+ return $type;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns all extensions for a certain file type
+ *
+ * @param string $type
+ * @return array
+ */
+ public static function extensions(string $type = null)
+ {
+ if ($type === null) {
+ return array_keys(Mime::types());
+ }
+
+ return static::$types[$type] ?? [];
+ }
+
+ /**
+ * Extracts the filename from a file path
+ *
+ *
+ *
+ * $filename = F::filename('/var/www/test.txt');
+ * // filename is test.txt
+ *
+ *
+ *
+ * @param string $name The path
+ * @return string
+ */
+ public static function filename(string $name): string
+ {
+ return pathinfo($name, PATHINFO_BASENAME);
+ }
+
+ /**
+ * Invalidate opcode cache for file.
+ *
+ * @param string $file The path of the file
+ * @return bool
+ */
+ public static function invalidateOpcodeCache(string $file): bool
+ {
+ if (function_exists('opcache_invalidate') && strlen(ini_get('opcache.restrict_api')) === 0) {
+ return opcache_invalidate($file, true);
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * Checks if a file is of a certain type
+ *
+ * @param string $file Full path to the file
+ * @param string $value An extension or mime type
+ * @return bool
+ */
+ public static function is(string $file, string $value): bool
+ {
+ // check for the extension
+ if (in_array($value, static::extensions()) === true) {
+ return static::extension($file) === $value;
+ }
+
+ // check for the mime type
+ if (strpos($value, '/') !== false) {
+ return static::mime($file) === $value;
+ }
+
+ return false;
+ }
+
+ /**
+ * Checks if the file is readable
+ *
+ * @param string $file
+ * @return bool
+ */
+ public static function isReadable(string $file): bool
+ {
+ return is_readable($file);
+ }
+
+ /**
+ * Checks if the file is writable
+ *
+ * @param string $file
+ * @return bool
+ */
+ public static function isWritable(string $file): bool
+ {
+ if (file_exists($file) === false) {
+ return is_writable(dirname($file));
+ }
+
+ return is_writable($file);
+ }
+
+ /**
+ * Create a (symbolic) link to a file
+ *
+ * @param string $source
+ * @param string $link
+ * @param string $method
+ * @return bool
+ */
+ public static function link(string $source, string $link, string $method = 'link'): bool
+ {
+ Dir::make(dirname($link), true);
+
+ if (is_file($link) === true) {
+ return true;
+ }
+
+ if (is_file($source) === false) {
+ throw new Exception(sprintf('The file "%s" does not exist and cannot be linked', $source));
+ }
+
+ try {
+ return $method($source, $link) === true;
+ } catch (Throwable $e) {
+ return false;
+ }
+ }
+
+ /**
+ * Loads a file and returns the result
+ *
+ * @param string $file
+ * @param mixed $fallback
+ * @return mixed
+ */
+ public static function load(string $file, $fallback = null)
+ {
+ if (file_exists($file) === false) {
+ return $fallback;
+ }
+
+ $result = include $file;
+
+ if ($fallback !== null && gettype($result) !== gettype($fallback)) {
+ return $fallback;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Returns the mime type of a file
+ *
+ * @param string $file
+ * @return string|false
+ */
+ public static function mime(string $file)
+ {
+ return Mime::type($file);
+ }
+
+ /**
+ * Converts a mime type to a file extension
+ *
+ * @param string $mime
+ * @return string|false
+ */
+ public static function mimeToExtension(string $mime = null)
+ {
+ return Mime::toExtension($mime);
+ }
+
+ /**
+ * Returns the type for a given mime
+ *
+ * @param string $mime
+ * @return string|false
+ */
+ public static function mimeToType(string $mime)
+ {
+ return static::extensionToType(Mime::toExtension($mime));
+ }
+
+ /**
+ * Get the file's last modification time.
+ *
+ * @param string $file
+ * @param string $format
+ * @param string $handler date or strftime
+ * @return mixed
+ */
+ public static function modified(string $file, string $format = null, string $handler = 'date')
+ {
+ if (file_exists($file) !== true) {
+ return false;
+ }
+
+ $stat = stat($file);
+ $mtime = $stat['mtime'] ?? 0;
+ $ctime = $stat['ctime'] ?? 0;
+ $modified = max([$mtime, $ctime]);
+
+ if (is_null($format) === true) {
+ return $modified;
+ }
+
+ return $handler($format, $modified);
+ }
+
+ /**
+ * Moves a file to a new location
+ *
+ * @param string $oldRoot The current path for the file
+ * @param string $newRoot The path to the new location
+ * @param bool $force Force move if the target file exists
+ * @return bool
+ */
+ public static function move(string $oldRoot, string $newRoot, bool $force = false): bool
+ {
+ // check if the file exists
+ if (file_exists($oldRoot) === false) {
+ return false;
+ }
+
+ if (file_exists($newRoot) === true) {
+ if ($force === false) {
+ return false;
+ }
+
+ // delete the existing file
+ static::remove($newRoot);
+ }
+
+ // actually move the file if it exists
+ if (rename($oldRoot, $newRoot) !== true) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Extracts the name from a file path or filename without extension
+ *
+ * @param string $name The path or filename
+ * @return string
+ */
+ public static function name(string $name): string
+ {
+ return pathinfo($name, PATHINFO_FILENAME);
+ }
+
+ /**
+ * Converts an integer size into a human readable format
+ *
+ * @param mixed $size The file size or a file path
+ * @return string|int
+ */
+ public static function niceSize($size): string
+ {
+ // file mode
+ if (is_string($size) === true && file_exists($size) === true) {
+ $size = static::size($size);
+ }
+
+ // make sure it's an int
+ $size = (int)$size;
+
+ // avoid errors for invalid sizes
+ if ($size <= 0) {
+ return '0 kB';
+ }
+
+ // the math magic
+ return round($size / pow(1024, ($i = floor(log($size, 1024)))), 2) . ' ' . static::$units[$i];
+ }
+
+ /**
+ * Reads the content of a file
+ *
+ * @param string $file The path for the file
+ * @return string|false
+ */
+ public static function read(string $file)
+ {
+ return @file_get_contents($file);
+ }
+
+ /**
+ * Changes the name of the file without
+ * touching the extension
+ *
+ * @param string $file
+ * @param string $newName
+ * @param bool $overwrite Force overwrite existing files
+ * @return string|false
+ */
+ public static function rename(string $file, string $newName, bool $overwrite = false)
+ {
+ // create the new name
+ $name = static::safeName(basename($newName));
+
+ // overwrite the root
+ $newRoot = rtrim(dirname($file) . '/' . $name . '.' . F::extension($file), '.');
+
+ // nothing has changed
+ if ($newRoot === $file) {
+ return $newRoot;
+ }
+
+ if (F::move($file, $newRoot) !== true) {
+ return false;
+ }
+
+ return $newRoot;
+ }
+
+ /**
+ * Returns the absolute path to the file if the file can be found.
+ *
+ * @param string $file
+ * @param string $in
+ * @return string|null
+ */
+ public static function realpath(string $file, string $in = null)
+ {
+ $realpath = realpath($file);
+
+ if ($realpath === false || is_file($realpath) === false) {
+ throw new Exception(sprintf('The file does not exist at the given path: "%s"', $file));
+ }
+
+ if ($in !== null) {
+ $parent = realpath($in);
+
+ if ($parent === false || is_dir($parent) === false) {
+ throw new Exception(sprintf('The parent directory does not exist: "%s"', $in));
+ }
+
+ if (substr($realpath, 0, strlen($parent)) !== $parent) {
+ throw new Exception('The file is not within the parent directory');
+ }
+ }
+
+ return $realpath;
+ }
+
+ /**
+ * Returns the relative path of the file
+ * starting after $in
+ *
+ * @param string $file
+ * @param string $in
+ * @return string
+ */
+ public static function relativepath(string $file, string $in = null): string
+ {
+ if (empty($in) === true) {
+ return basename($file);
+ }
+
+ // windows
+ $file = str_replace('\\', '/', $file);
+ $in = str_replace('\\', '/', $in);
+
+ if (Str::contains($file, $in) === false) {
+ return basename($file);
+ }
+
+ return Str::after($file, $in);
+ }
+
+ /**
+ * Deletes a file
+ *
+ *
+ *
+ * $remove = F::remove('test.txt');
+ * if($remove) echo 'The file has been removed';
+ *
+ *
+ *
+ * @param string $file The path for the file
+ * @return bool
+ */
+ public static function remove(string $file): bool
+ {
+ if (strpos($file, '*') !== false) {
+ foreach (glob($file) as $f) {
+ static::remove($f);
+ }
+
+ return true;
+ }
+
+ $file = realpath($file);
+
+ if (file_exists($file) === false) {
+ return true;
+ }
+
+ return unlink($file);
+ }
+
+ /**
+ * Sanitize a filename to strip unwanted special characters
+ *
+ *
+ *
+ * $safe = f::safeName('über genious.txt');
+ * // safe will be ueber-genious.txt
+ *
+ *
+ *
+ * @param string $string The file name
+ * @return string
+ */
+ public static function safeName(string $string): string
+ {
+ $name = static::name($string);
+ $extension = static::extension($string);
+ $safeName = Str::slug($name, '-', 'a-z0-9@._-');
+ $safeExtension = empty($extension) === false ? '.' . Str::slug($extension) : '';
+
+ return $safeName . $safeExtension;
+ }
+
+ /**
+ * Tries to find similar or the same file by
+ * building a glob based on the path
+ *
+ * @param string $path
+ * @param string $pattern
+ * @return array
+ */
+ public static function similar(string $path, string $pattern = '*'): array
+ {
+ $dir = dirname($path);
+ $name = static::name($path);
+ $extension = static::extension($path);
+ $glob = $dir . '/' . $name . $pattern . '.' . $extension;
+ return glob($glob);
+ }
+
+ /**
+ * Returns the size of a file.
+ *
+ * @param mixed $file The path
+ * @return int
+ */
+ public static function size(string $file): int
+ {
+ try {
+ return filesize($file);
+ } catch (Throwable $e) {
+ return 0;
+ }
+ }
+
+ /**
+ * Categorize the file
+ *
+ * @param string $file Either the file path or extension
+ * @return string|null
+ */
+ public static function type(string $file)
+ {
+ $length = strlen($file);
+
+ if ($length >= 2 && $length <= 4) {
+ // use the file name as extension
+ $extension = $file;
+ } else {
+ // get the extension from the filename
+ $extension = pathinfo($file, PATHINFO_EXTENSION);
+ }
+
+ if (empty($extension) === true) {
+ // detect the mime type first to get the most reliable extension
+ $mime = static::mime($file);
+ $extension = static::mimeToExtension($mime);
+ }
+
+ // sanitize extension
+ $extension = strtolower($extension);
+
+ foreach (static::$types as $type => $extensions) {
+ if (in_array($extension, $extensions) === true) {
+ return $type;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Unzips a zip file
+ *
+ * @param string $file
+ * @param string $to
+ * @return bool
+ */
+ public static function unzip(string $file, string $to): bool
+ {
+ if (class_exists('ZipArchive') === false) {
+ throw new Exception('The ZipArchive class is not available');
+ }
+
+ $zip = new ZipArchive();
+
+ if ($zip->open($file) === true) {
+ $zip->extractTo($to);
+ $zip->close();
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns the file as data uri
+ *
+ * @param string $file The path for the file
+ * @return string|false
+ */
+ public static function uri(string $file)
+ {
+ if ($mime = static::mime($file)) {
+ return 'data:' . $mime . ';base64,' . static::base64($file);
+ }
+
+ return false;
+ }
+
+ /**
+ * Creates a new file
+ *
+ * @param string $file The path for the new file
+ * @param mixed $content Either a string, an object or an array. Arrays and objects will be serialized.
+ * @param bool $append true: append the content to an exisiting file if available. false: overwrite.
+ * @return bool
+ */
+ public static function write(string $file, $content, bool $append = false): bool
+ {
+ if (is_array($content) === true || is_object($content) === true) {
+ $content = serialize($content);
+ }
+
+ $mode = $append === true ? FILE_APPEND | LOCK_EX : LOCK_EX;
+
+ // if the parent directory does not exist, create it
+ if (is_dir(dirname($file)) === false) {
+ if (Dir::make(dirname($file)) === false) {
+ return false;
+ }
+ }
+
+ if (static::isWritable($file) === false) {
+ throw new Exception('The file "' . $file . '" is not writable');
+ }
+
+ return file_put_contents($file, $content, $mode) !== false;
+ }
+}
diff --git a/kirby/src/Toolkit/Facade.php b/kirby/src/Toolkit/Facade.php
new file mode 100755
index 0000000..3387a97
--- /dev/null
+++ b/kirby/src/Toolkit/Facade.php
@@ -0,0 +1,36 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+abstract class Facade
+{
+ /**
+ * Returns the instance that should be
+ * available statically
+ *
+ * @return mixed
+ */
+ abstract public static function instance();
+
+ /**
+ * Proxy for all public instance calls
+ *
+ * @param string $method
+ * @param array $args
+ * @return mixed
+ */
+ public static function __callStatic(string $method, array $args = null)
+ {
+ return static::instance()->$method(...$args);
+ }
+}
diff --git a/kirby/src/Toolkit/File.php b/kirby/src/Toolkit/File.php
new file mode 100755
index 0000000..7a43a5c
--- /dev/null
+++ b/kirby/src/Toolkit/File.php
@@ -0,0 +1,340 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class File
+{
+ /**
+ * Absolute file path
+ *
+ * @var string
+ */
+ protected $root;
+
+ /**
+ * Constructs a new File object by absolute path
+ *
+ * @param string $root Absolute file path
+ */
+ public function __construct(string $root = null)
+ {
+ $this->root = $root;
+ }
+
+ /**
+ * Improved `var_dump` output
+ *
+ * @return array
+ */
+ public function __debugInfo(): array
+ {
+ return $this->toArray();
+ }
+
+ /**
+ * Returns the file content as base64 encoded string
+ *
+ * @return string
+ */
+ public function base64(): string
+ {
+ return base64_encode($this->read());
+ }
+
+ /**
+ * Copy a file to a new location.
+ *
+ * @param string $target
+ * @param bool $force
+ * @return self
+ */
+ public function copy(string $target, bool $force = false)
+ {
+ if (F::copy($this->root, $target, $force) !== true) {
+ throw new Exception('The file "' . $this->root . '" could not be copied');
+ }
+
+ return new static($target);
+ }
+
+ /**
+ * Returns the file as data uri
+ *
+ * @param bool $base64 Whether the data should be base64 encoded or not
+ * @return string
+ */
+ public function dataUri(bool $base64 = true): string
+ {
+ if ($base64 === true) {
+ return 'data:' . $this->mime() . ';base64,' . $this->base64();
+ }
+
+ return 'data:' . $this->mime() . ',' . Escape::url($this->read());
+ }
+
+ /**
+ * Deletes the file
+ *
+ * @return bool
+ */
+ public function delete(): bool
+ {
+ if (F::remove($this->root) !== true) {
+ throw new Exception('The file "' . $this->root . '" could not be deleted');
+ }
+
+ return true;
+ }
+
+ /**
+ * Checks if the file actually exists
+ *
+ * @return bool
+ */
+ public function exists(): bool
+ {
+ return file_exists($this->root) === true;
+ }
+
+ /**
+ * Returns the current lowercase extension (without .)
+ *
+ * @return string
+ */
+ public function extension(): string
+ {
+ return F::extension($this->root);
+ }
+
+ /**
+ * Returns the filename
+ *
+ * @return string
+ */
+ public function filename(): string
+ {
+ return basename($this->root);
+ }
+
+ /**
+ * Returns a md5 hash of the root
+ *
+ * @return string
+ */
+ public function hash(): string
+ {
+ return md5($this->root);
+ }
+
+ /**
+ * Checks if a file is of a certain type
+ *
+ * @param string $value An extension or mime type
+ * @return bool
+ */
+ public function is(string $value): bool
+ {
+ return F::is($this->root, $value);
+ }
+
+ /**
+ * Checks if the file is readable
+ *
+ * @return bool
+ */
+ public function isReadable(): bool
+ {
+ return is_readable($this->root) === true;
+ }
+
+ /**
+ * Checks if the file is writable
+ *
+ * @return bool
+ */
+ public function isWritable(): bool
+ {
+ return F::isWritable($this->root);
+ }
+
+ /**
+ * Detects the mime type of the file
+ *
+ * @return string|null
+ */
+ public function mime()
+ {
+ return Mime::type($this->root);
+ }
+
+ /**
+ * Get the file's last modification time.
+ *
+ * @param string $format
+ * @param string $handler date or strftime
+ * @return mixed
+ */
+ public function modified(string $format = null, string $handler = 'date')
+ {
+ return F::modified($this->root, $format, $handler);
+ }
+
+ /**
+ * Move the file to a new location
+ *
+ * @param string $newRoot
+ * @param bool $overwrite Force overwriting any existing files
+ * @return self
+ */
+ public function move(string $newRoot, bool $overwrite = false)
+ {
+ if (F::move($this->root, $newRoot, $overwrite) !== true) {
+ throw new Exception('The file: "' . $this->root . '" could not be moved to: "' . $newRoot . '"');
+ }
+
+ return new static($newRoot);
+ }
+
+ /**
+ * Getter for the name of the file
+ * without the extension
+ *
+ * @return string
+ */
+ public function name(): string
+ {
+ return pathinfo($this->root, PATHINFO_FILENAME);
+ }
+
+ /**
+ * Returns the file size in a
+ * human-readable format
+ *
+ * @return string
+ */
+ public function niceSize(): string
+ {
+ return F::niceSize($this->root);
+ }
+
+ /**
+ * Reads the file content and returns it.
+ *
+ * @return string
+ */
+ public function read()
+ {
+ return F::read($this->root);
+ }
+
+ /**
+ * Returns the absolute path to the file
+ *
+ * @return string
+ */
+ public function realpath(): string
+ {
+ return realpath($this->root);
+ }
+
+ /**
+ * Changes the name of the file without
+ * touching the extension
+ *
+ * @param string $newName
+ * @param bool $overwrite Force overwrite existing files
+ * @return self
+ */
+ public function rename(string $newName, bool $overwrite = false)
+ {
+ $newRoot = F::rename($this->root, $newName, $overwrite);
+
+ if ($newRoot === false) {
+ throw new Exception('The file: "' . $this->root . '" could not be renamed to: "' . $newName . '"');
+ }
+
+ return new static($newRoot);
+ }
+
+ /**
+ * Returns the given file path
+ *
+ * @return string|null
+ */
+ public function root(): ?string
+ {
+ return $this->root;
+ }
+
+ /**
+ * Returns the raw size of the file
+ *
+ * @return int
+ */
+ public function size(): int
+ {
+ return F::size($this->root);
+ }
+
+ /**
+ * Converts the media object to a
+ * plain PHP array
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return [
+ 'root' => $this->root(),
+ 'hash' => $this->hash(),
+ 'filename' => $this->filename(),
+ 'name' => $this->name(),
+ 'safeName' => F::safeName($this->name()),
+ 'extension' => $this->extension(),
+ 'size' => $this->size(),
+ 'niceSize' => $this->niceSize(),
+ 'modified' => $this->modified('c'),
+ 'mime' => $this->mime(),
+ 'type' => $this->type(),
+ 'isWritable' => $this->isWritable(),
+ 'isReadable' => $this->isReadable(),
+ ];
+ }
+
+ /**
+ * Returns the file type.
+ *
+ * @return string|false
+ */
+ public function type()
+ {
+ return F::type($this->root);
+ }
+
+ /**
+ * Writes content to the file
+ *
+ * @param string $content
+ * @return bool
+ */
+ public function write($content): bool
+ {
+ if (F::write($this->root, $content) !== true) {
+ throw new Exception('The file "' . $this->root . '" could not be written');
+ }
+
+ return true;
+ }
+}
diff --git a/kirby/src/Toolkit/Html.php b/kirby/src/Toolkit/Html.php
new file mode 100755
index 0000000..de0b535
--- /dev/null
+++ b/kirby/src/Toolkit/Html.php
@@ -0,0 +1,536 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Html
+{
+ /**
+ * An internal store for a html entities translation table
+ *
+ * @var array
+ */
+ public static $entities;
+
+ /**
+ * Can be used to switch to trailing slashes if required
+ *
+ * ```php
+ * html::$void = ' />'
+ * ```
+ *
+ * @var string $void
+ */
+ public static $void = '>';
+
+ /**
+ * Generic HTML tag generator
+ *
+ * @param string $tag
+ * @param array $arguments
+ * @return string
+ */
+ public static function __callStatic(string $tag, array $arguments = []): string
+ {
+ if (static::isVoid($tag) === true) {
+ return Html::tag($tag, null, ...$arguments);
+ }
+
+ return Html::tag($tag, ...$arguments);
+ }
+
+ /**
+ * Generates an `a` tag
+ *
+ * @param string $href The url for the `a` tag
+ * @param mixed $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 = null, $text = null, array $attr = []): string
+ {
+ if (Str::startsWith($href, 'mailto:')) {
+ return static::email($href, $text, $attr);
+ }
+
+ if (Str::startsWith($href, 'tel:')) {
+ return static::tel($href, $text, $attr);
+ }
+
+ return static::link($href, $text, $attr);
+ }
+
+ /**
+ * Generates a single attribute or a list of attributes
+ *
+ * @param string $name mixed string: a single attribute with that name will be generated. array: a list of attributes will be generated. Don't pass a second argument in that case.
+ * @param string $value if used for a single attribute, pass the content for the attribute here
+ * @return string the generated html
+ */
+ public static function attr($name, $value = null): string
+ {
+ if (is_array($name) === true) {
+ $attributes = [];
+
+ ksort($name);
+
+ foreach ($name as $key => $val) {
+ $a = static::attr($key, $val);
+
+ if ($a) {
+ $attributes[] = $a;
+ }
+ }
+
+ return implode(' ', $attributes);
+ }
+
+ if ($value === null || $value === '' || $value === []) {
+ return false;
+ }
+
+ if ($value === ' ') {
+ return strtolower($name) . '=""';
+ }
+
+ if (is_bool($value) === true) {
+ return $value === true ? strtolower($name) : '';
+ }
+
+ if (is_array($value) === true) {
+ if (isset($value['value'], $value['escape'])) {
+ $value = $value['escape'] === true ? htmlspecialchars($value['value'], ENT_QUOTES, 'UTF-8') : $value['value'];
+ } else {
+ $value = implode(' ', array_filter($value, function ($value) {
+ return !empty($value) || is_numeric($value);
+ }));
+ }
+ } else {
+ $value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
+ }
+
+ return strtolower($name) . '="' . $value . '"';
+ }
+
+ /**
+ * Converts lines in a string into html breaks
+ *
+ * @param string $string
+ * @return string
+ */
+ public static function breaks(string $string = null): string
+ {
+ return nl2br($string);
+ }
+
+ /**
+ * Removes all html tags and encoded chars from a string
+ *
+ *
+ *
+ * echo html::decode('some uber crazy stuff');
+ * // output: some uber crazy stuff
+ *
+ *
+ *
+ * @param string $string
+ * @return string The html string
+ */
+ public static function decode(string $string = null): string
+ {
+ $string = strip_tags($string);
+ return html_entity_decode($string, ENT_COMPAT, 'utf-8');
+ }
+
+ /**
+ * Generates an `a` tag with `mailto:`
+ *
+ * @param string $email The url for the a tag
+ * @param mixed $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 email(string $email, $text = null, array $attr = []): string
+ {
+ if (empty($email) === true) {
+ return '';
+ }
+
+ if (empty($text) === true) {
+ // show only the eMail address without additional parameters (if the 'text' argument is empty)
+ $text = [Str::encode(Str::split($email, '?')[0])];
+ }
+
+ $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 a html-safe string
+ *
+ * @param string $string
+ * @param bool $keepTags
+ * @return string The html string
+ */
+ public static function encode(string $string = null, bool $keepTags = false): string
+ {
+ 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_COMPAT, 'utf-8');
+ }
+
+ /**
+ * Returns the entities translation table
+ *
+ * @return array
+ */
+ public static function entities(): array
+ {
+ return static::$entities = static::$entities ?? get_html_translation_table(HTML_ENTITIES);
+ }
+
+ /**
+ * Creates a figure tag with optional caption
+ *
+ * @param string|array $content
+ * @param string|array $caption
+ * @param array $attr
+ * @return string
+ */
+ public static function figure($content, $caption = null, 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 gist
+ *
+ * @param string $url
+ * @param string $file
+ * @param array $attr
+ * @return string
+ */
+ public static function gist(string $url, string $file = null, array $attr = []): string
+ {
+ if ($file === null) {
+ $src = $url . '.js';
+ } else {
+ $src = $url . '.js?file=' . $file;
+ }
+
+ return static::tag('script', null, array_merge($attr, [
+ 'src' => $src
+ ]));
+ }
+
+ /**
+ * Creates an iframe
+ *
+ * @param string $src
+ * @param array $attr
+ * @return string
+ */
+ public static function iframe(string $src, array $attr = []): string
+ {
+ return static::tag('iframe', null, array_merge(['src' => $src], $attr));
+ }
+
+ /**
+ * Generates an img tag
+ *
+ * @param string $src The url of the image
+ * @param array $attr Additional attributes for the image 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', null, $attr);
+ }
+
+ /**
+ * Checks if a tag is self-closing
+ *
+ * @param string $tag
+ * @return bool
+ */
+ public static function isVoid(string $tag): bool
+ {
+ $void = [
+ 'area',
+ 'base',
+ 'br',
+ 'col',
+ 'command',
+ 'embed',
+ 'hr',
+ 'img',
+ 'input',
+ 'keygen',
+ 'link',
+ 'meta',
+ 'param',
+ 'source',
+ 'track',
+ 'wbr',
+ ];
+
+ return in_array(strtolower($tag), $void);
+ }
+
+ /**
+ * Generates an `a` link tag
+ *
+ * @param string $href The url for the `a` tag
+ * @param mixed $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 = null, $text = null, array $attr = []): string
+ {
+ $attr = array_merge(['href' => $href], $attr);
+
+ if (empty($text) === true) {
+ $text = $attr['href'];
+ }
+
+ if (is_string($text) === true && Str::isUrl($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 noopeener noreferrer to rels when target is `_blank`
+ *
+ * @param string $rel
+ * @param string $target
+ * @return string|null
+ */
+ public static function rel(string $rel = null, string $target = null)
+ {
+ $rel = trim($rel);
+
+ if ($target === '_blank') {
+ if (empty($rel) === false) {
+ return $rel;
+ }
+
+ return trim($rel . ' noopener noreferrer', ' ');
+ }
+
+ return $rel;
+ }
+
+ /**
+ * Generates an Html tag with optional content and attributes
+ *
+ * @param string $name The name of the tag, i.e. `a`
+ * @param mixed $content The content if availble. Pass `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
+ * @return string The generated Html
+ */
+ public static function tag(string $name, $content = null, array $attr = []): string
+ {
+ $html = '<' . $name;
+ $attr = static::attr($attr);
+
+ if (empty($attr) === false) {
+ $html .= ' ' . $attr;
+ }
+
+ if (static::isVoid($name) === true) {
+ $html .= static::$void;
+ } else {
+ if (is_array($content) === true) {
+ $content = implode($content);
+ } else {
+ $content = static::encode($content, false);
+ }
+
+ $html .= '>' . $content . '' . $name . '>';
+ }
+
+ return $html;
+ }
+
+
+ /**
+ * Generates an `a` tag for a phone number
+ *
+ * @param string $tel The phone number
+ * @param mixed $text The optional text. If `null`, the number will be used as text
+ * @param array $attr Additional attributes for the tag
+ * @return string the generated html
+ */
+ public static function tel($tel = 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);
+ }
+
+ /**
+ * Creates a video embed via iframe for Youtube or Vimeo
+ * videos. The embed Urls are automatically detected from
+ * the given URL.
+ *
+ * @param string $url
+ * @param array $options
+ * @param array $attr
+ * @return string
+ */
+ public static function video(string $url, ?array $options = [], array $attr = []): string
+ {
+ // YouTube video
+ if (preg_match('!youtu!i', $url) === 1) {
+ return static::youtube($url, $options['youtube'] ?? [], $attr);
+ }
+
+ // Vimeo video
+ if (preg_match('!vimeo!i', $url) === 1) {
+ return static::vimeo($url, $options['vimeo'] ?? [], $attr);
+ }
+
+ throw new Exception('Unexpected video type');
+ }
+
+ /**
+ * Embeds a Vimeo video by URL in an iframe
+ *
+ * @param string $url
+ * @param array $options
+ * @param array $attr
+ * @return string
+ */
+ public static function vimeo(string $url, ?array $options = [], array $attr = []): string
+ {
+ if (preg_match('!vimeo.com\/([0-9]+)!i', $url, $array) === 1) {
+ $id = $array[1];
+ } elseif (preg_match('!player.vimeo.com\/video\/([0-9]+)!i', $url, $array) === 1) {
+ $id = $array[1];
+ } else {
+ throw new Exception('Invalid Vimeo source');
+ }
+
+ // build the options query
+ if (empty($options) === false) {
+ $query = '?' . http_build_query($options);
+ } else {
+ $query = '';
+ }
+
+ $url = 'https://player.vimeo.com/video/' . $id . $query;
+
+ return static::iframe($url, array_merge(['allowfullscreen' => true], $attr));
+ }
+
+ /**
+ * Embeds a Youtube video by URL in an iframe
+ *
+ * @param string $url
+ * @param array $options
+ * @param array $attr
+ * @return string
+ */
+ public static function youtube(string $url, ?array $options = [], array $attr = []): string
+ {
+ // youtube embed domain
+ $domain = 'youtube.com';
+ $id = null;
+
+ $schemes = [
+ // http://www.youtube.com/embed/d9NF2edxy-M
+ ['pattern' => 'youtube.com\/embed\/([a-zA-Z0-9_-]+)'],
+ // https://www.youtube-nocookie.com/embed/d9NF2edxy-M
+ [
+ 'pattern' => 'youtube-nocookie.com\/embed\/([a-zA-Z0-9_-]+)',
+ 'domain' => 'www.youtube-nocookie.com'
+ ],
+ // https://www.youtube-nocookie.com/watch?v=d9NF2edxy-M
+ [
+ 'pattern' => 'youtube-nocookie.com\/watch\?v=([a-zA-Z0-9_-]+)',
+ 'domain' => 'www.youtube-nocookie.com'
+ ],
+ // http://www.youtube.com/watch?v=d9NF2edxy-M
+ ['pattern' => 'v=([a-zA-Z0-9_-]+)'],
+ // http://youtu.be/d9NF2edxy-M
+ ['pattern' => 'youtu.be\/([a-zA-Z0-9_-]+)']
+ ];
+
+ foreach ($schemes as $schema) {
+ if (preg_match('!' . $schema['pattern'] . '!i', $url, $array) === 1) {
+ $domain = $schema['domain'] ?? $domain;
+ $id = $array[1];
+ break;
+ }
+ }
+
+ // no match
+ if ($id === null) {
+ throw new Exception('Invalid Youtube source');
+ }
+
+ // build the options query
+ if (empty($options) === false) {
+ $query = '?' . http_build_query($options);
+ } else {
+ $query = '';
+ }
+
+ $url = 'https://' . $domain . '/embed/' . $id . $query;
+
+ return static::iframe($url, array_merge(['allowfullscreen' => true], $attr));
+ }
+}
diff --git a/kirby/src/Toolkit/I18n.php b/kirby/src/Toolkit/I18n.php
new file mode 100755
index 0000000..8e44cdf
--- /dev/null
+++ b/kirby/src/Toolkit/I18n.php
@@ -0,0 +1,230 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class I18n
+{
+ /**
+ * Custom loader function
+ *
+ * @var Closure
+ */
+ public static $load = null;
+
+ /**
+ * Current locale
+ *
+ * @var string
+ */
+ public static $locale = 'en';
+
+ /**
+ * All registered translations
+ *
+ * @var array
+ */
+ public static $translations = [];
+
+ /**
+ * The fallback locale
+ *
+ * @var string
+ */
+ public static $fallback = 'en';
+
+ /**
+ * Returns the fallback code
+ *
+ * @return string
+ */
+ public static function fallback(): string
+ {
+ if (is_string(static::$fallback) === true) {
+ return static::$fallback;
+ }
+
+ if (is_callable(static::$fallback) === true) {
+ return static::$fallback = (static::$fallback)();
+ }
+
+ return static::$fallback = 'en';
+ }
+
+ /**
+ * Returns singular or plural
+ * depending on the given number
+ *
+ * @param int $count
+ * @param bool $none If true, 'none' will be returned if the count is 0
+ * @return string
+ */
+ public static function form(int $count, bool $none = false): string
+ {
+ if ($none === true && $count === 0) {
+ return 'none';
+ }
+
+ return $count === 1 ? 'singular' : 'plural';
+ }
+
+ /**
+ * Returns the locale code
+ *
+ * @return string
+ */
+ public static function locale(): string
+ {
+ if (is_string(static::$locale) === true) {
+ return static::$locale;
+ }
+
+ if (is_callable(static::$locale) === true) {
+ return static::$locale = (static::$locale)();
+ }
+
+ return static::$locale = 'en';
+ }
+
+ /**
+ * Translates a given message
+ * according to the currently set locale
+ *
+ * @param string|array $key
+ * @param string|array|null $fallback
+ * @param string|null $locale
+ * @return string|array|null
+ */
+ public static function translate($key, $fallback = null, string $locale = null)
+ {
+ $locale = $locale ?? static::locale();
+
+ if (is_array($key) === true) {
+ if (isset($key[$locale])) {
+ return $key[$locale];
+ }
+ if (is_array($fallback)) {
+ return $fallback[$locale] ?? $fallback['en'] ?? reset($fallback);
+ }
+ return $fallback;
+ }
+
+ if ($translation = static::translation($locale)[$key] ?? null) {
+ return $translation;
+ }
+
+ if ($fallback !== null) {
+ return $fallback;
+ }
+
+ if ($locale !== static::fallback()) {
+ return static::translation(static::fallback())[$key] ?? null;
+ }
+
+ return null;
+ }
+
+ /**
+ * Translate by key and then replace
+ * placeholders in the text
+ *
+ * @param string $key
+ * @param string $fallback
+ * @param array $replace
+ * @param string $locale
+ * @return string
+ */
+ public static function template(string $key, $fallback = null, array $replace = null, string $locale = null)
+ {
+ if (is_array($fallback) === true) {
+ $replace = $fallback;
+ $fallback = null;
+ $locale = null;
+ }
+
+ $template = static::translate($key, $fallback, $locale);
+ return Str::template($template, $replace, '-', '{', '}');
+ }
+
+ /**
+ * 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.
+ *
+ * @param string|null $locale
+ * @return array
+ */
+ public static function translation(string $locale = null): array
+ {
+ $locale = $locale ?? static::locale();
+
+ if (isset(static::$translations[$locale]) === true) {
+ return static::$translations[$locale];
+ }
+
+ if (is_a(static::$load, 'Closure') === true) {
+ return static::$translations[$locale] = (static::$load)($locale);
+ }
+
+ return static::$translations[$locale] = [];
+ }
+
+ /**
+ * Returns all loaded or defined translations
+ *
+ * @return array
+ */
+ public static function translations(): array
+ {
+ return static::$translations;
+ }
+
+ /**
+ * Translate amounts
+ *
+ * @param string $key
+ * @param int $count
+ * @param string $locale
+ * @return mixed
+ */
+ public static function translateCount(string $key, int $count, string $locale = null)
+ {
+ $translation = static::translate($key, null, $locale);
+
+ if ($translation === null) {
+ return null;
+ }
+
+ if (is_string($translation) === true) {
+ return $translation;
+ }
+
+ if (count($translation) !== 3) {
+ throw new Exception('Please provide 3 translations');
+ }
+
+ switch ($count) {
+ case 0:
+ $message = $translation[0];
+ break;
+ case 1:
+ $message = $translation[1];
+ break;
+ default:
+ $message = $translation[2];
+ }
+
+ return str_replace('{{ count }}', $count, $message);
+ }
+}
diff --git a/kirby/src/Toolkit/Iterator.php b/kirby/src/Toolkit/Iterator.php
new file mode 100755
index 0000000..81fb2ae
--- /dev/null
+++ b/kirby/src/Toolkit/Iterator.php
@@ -0,0 +1,181 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Iterator implements IteratorAggregate
+{
+ /**
+ * The data array
+ *
+ * @var array
+ */
+ public $data = [];
+
+ /**
+ * Constructor
+ *
+ * @param array $data
+ */
+ public function __construct(array $data = [])
+ {
+ $this->data = $data;
+ }
+
+ /**
+ * Get an iterator for the items.
+ *
+ * @return \ArrayIterator
+ */
+ public function getIterator()
+ {
+ return new ArrayIterator($this->data);
+ }
+
+ /**
+ * Returns the current key
+ *
+ * @return string
+ */
+ public function key()
+ {
+ return key($this->data);
+ }
+
+ /**
+ * Returns an array of all keys
+ *
+ * @return array
+ */
+ public function keys(): array
+ {
+ return array_keys($this->data);
+ }
+
+ /**
+ * Returns the current element
+ *
+ * @return mixed
+ */
+ public function current()
+ {
+ return current($this->data);
+ }
+
+ /**
+ * Moves the cursor to the previous element
+ * and returns it
+ *
+ * @return mixed
+ */
+ public function prev()
+ {
+ return prev($this->data);
+ }
+
+ /**
+ * Moves the cursor to the next element
+ * and returns it
+ *
+ * @return mixed
+ */
+ public function next()
+ {
+ return next($this->data);
+ }
+
+ /**
+ * Moves the cusor to the first element
+ */
+ public function rewind()
+ {
+ reset($this->data);
+ }
+
+ /**
+ * Checks if the current element is valid
+ *
+ * @return bool
+ */
+ public function valid(): bool
+ {
+ return $this->current() !== false;
+ }
+
+ /**
+ * Counts all elements
+ *
+ * @return int
+ */
+ 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 string|false the name of the key or false
+ */
+ public function indexOf($needle)
+ {
+ 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 string|false the name of the key or false
+ */
+ public function keyOf($needle)
+ {
+ return array_search($needle, $this->data);
+ }
+
+ /**
+ * Checks by key if an element is included
+ *
+ * @param mixed $key
+ * @return bool
+ */
+ public function has($key): bool
+ {
+ return isset($this->data[$key]);
+ }
+
+ /**
+ * Checks if the current key is set
+ *
+ * @param mixed $key the key to check
+ * @return bool
+ */
+ public function __isset($key): bool
+ {
+ return $this->has($key);
+ }
+
+ /**
+ * Simplified var_dump output
+ *
+ * @return array
+ */
+ public function __debugInfo(): array
+ {
+ return $this->data;
+ }
+}
diff --git a/kirby/src/Toolkit/Mime.php b/kirby/src/Toolkit/Mime.php
new file mode 100755
index 0000000..52ce12b
--- /dev/null
+++ b/kirby/src/Toolkit/Mime.php
@@ -0,0 +1,340 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Mime
+{
+ /**
+ * Extension to MIME type map
+ *
+ * @var array
+ */
+ public static $types = [
+ 'ai' => 'application/postscript',
+ 'aif' => 'audio/x-aiff',
+ 'aifc' => 'audio/x-aiff',
+ 'aiff' => 'audio/x-aiff',
+ 'avi' => 'video/x-msvideo',
+ 'bmp' => 'image/bmp',
+ 'css' => 'text/css',
+ 'csv' => ['text/csv', 'text/x-comma-separated-values', 'text/comma-separated-values', 'application/octet-stream'],
+ 'doc' => 'application/msword',
+ 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'dotx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.template',
+ 'dvi' => 'application/x-dvi',
+ 'eml' => 'message/rfc822',
+ 'eps' => 'application/postscript',
+ 'exe' => ['application/octet-stream', 'application/x-msdownload'],
+ 'gif' => 'image/gif',
+ 'gtar' => 'application/x-gtar',
+ 'gz' => 'application/x-gzip',
+ 'htm' => 'text/html',
+ 'html' => 'text/html',
+ 'ico' => 'image/x-icon',
+ 'ics' => 'text/calendar',
+ 'js' => 'application/x-javascript',
+ 'json' => ['application/json', 'text/json'],
+ 'jpg' => ['image/jpeg', 'image/pjpeg'],
+ 'jpeg' => ['image/jpeg', 'image/pjpeg'],
+ 'jpe' => ['image/jpeg', 'image/pjpeg'],
+ 'log' => ['text/plain', 'text/x-log'],
+ 'm4a' => 'audio/mp4',
+ 'm4v' => 'video/mp4',
+ 'mid' => 'audio/midi',
+ 'midi' => 'audio/midi',
+ 'mif' => 'application/vnd.mif',
+ 'mov' => 'video/quicktime',
+ 'movie' => 'video/x-sgi-movie',
+ 'mp2' => 'audio/mpeg',
+ 'mp3' => ['audio/mpeg', 'audio/mpg', 'audio/mpeg3', 'audio/mp3'],
+ 'mp4' => 'video/mp4',
+ 'mpe' => 'video/mpeg',
+ 'mpeg' => 'video/mpeg',
+ 'mpg' => 'video/mpeg',
+ 'mpga' => 'audio/mpeg',
+ 'odc' => 'application/vnd.oasis.opendocument.chart',
+ 'odp' => 'application/vnd.oasis.opendocument.presentation',
+ 'odt' => 'application/vnd.oasis.opendocument.text',
+ 'pdf' => ['application/pdf', 'application/x-download'],
+ 'php' => ['text/php', 'text/x-php', 'application/x-httpd-php', 'application/php', 'application/x-php', 'application/x-httpd-php-source'],
+ 'php3' => ['text/php', 'text/x-php', 'application/x-httpd-php', 'application/php', 'application/x-php', 'application/x-httpd-php-source'],
+ 'phps' => ['text/php', 'text/x-php', 'application/x-httpd-php', 'application/php', 'application/x-php', 'application/x-httpd-php-source'],
+ 'phtml' => ['text/php', 'text/x-php', 'application/x-httpd-php', 'application/php', 'application/x-php', 'application/x-httpd-php-source'],
+ 'png' => 'image/png',
+ 'ppt' => ['application/powerpoint', 'application/vnd.ms-powerpoint'],
+ 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ 'potx' => 'application/vnd.openxmlformats-officedocument.presentationml.template',
+ 'ps' => 'application/postscript',
+ 'psd' => 'application/x-photoshop',
+ 'qt' => 'video/quicktime',
+ 'rss' => 'application/rss+xml',
+ 'rtf' => 'text/rtf',
+ 'rtx' => 'text/richtext',
+ 'shtml' => 'text/html',
+ 'svg' => 'image/svg+xml',
+ 'swf' => 'application/x-shockwave-flash',
+ 'tar' => 'application/x-tar',
+ 'text' => 'text/plain',
+ 'txt' => 'text/plain',
+ 'tgz' => ['application/x-tar', 'application/x-gzip-compressed'],
+ 'tif' => 'image/tiff',
+ 'tiff' => 'image/tiff',
+ 'wav' => 'audio/x-wav',
+ 'wbxml' => 'application/wbxml',
+ 'webm' => 'video/webm',
+ 'webp' => 'image/webp',
+ 'word' => ['application/msword', 'application/octet-stream'],
+ 'xhtml' => 'application/xhtml+xml',
+ 'xht' => 'application/xhtml+xml',
+ 'xml' => 'text/xml',
+ 'xl' => 'application/excel',
+ 'xls' => ['application/excel', 'application/vnd.ms-excel', 'application/msexcel'],
+ 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'xltx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.template',
+ 'xsl' => 'text/xml',
+ 'yaml' => ['application/yaml', 'text/yaml'],
+ 'yml' => ['application/yaml', 'text/yaml'],
+ 'zip' => ['application/x-zip', 'application/zip', 'application/x-zip-compressed'],
+ ];
+
+ /**
+ * Fixes an invalid MIME type guess for the given file
+ *
+ * @param string $file
+ * @param string $mime
+ * @param string $extension
+ * @return string|null
+ */
+ public static function fix(string $file, string $mime = null, string $extension = null)
+ {
+ // fixing map
+ $map = [
+ 'text/html' => [
+ 'svg' => ['Kirby\Toolkit\Mime', 'fromSvg'],
+ ],
+ 'text/plain' => [
+ 'css' => 'text/css',
+ 'json' => 'application/json',
+ 'svg' => ['Kirby\Toolkit\Mime', 'fromSvg'],
+ ],
+ 'text/x-asm' => [
+ 'css' => 'text/css'
+ ],
+ 'image/svg' => [
+ 'svg' => 'image/svg+xml'
+ ]
+ ];
+
+ if ($mode = ($map[$mime][$extension] ?? null)) {
+ if (is_callable($mode) === true) {
+ return $mode($file, $mime, $extension);
+ }
+
+ if (is_string($mode) === true) {
+ return $mode;
+ }
+ }
+
+ return $mime;
+ }
+
+ /**
+ * Guesses a MIME type by extension
+ *
+ * @param string $extension
+ * @return string|null
+ */
+ public static function fromExtension(string $extension)
+ {
+ $mime = static::$types[$extension] ?? null;
+ return is_array($mime) === true ? array_shift($mime) : $mime;
+ }
+
+ /**
+ * Returns the MIME type of a file
+ *
+ * @param string $file
+ * @return string|false
+ */
+ public static function fromFileInfo(string $file)
+ {
+ if (function_exists('finfo_file') === true && file_exists($file) === true) {
+ $finfo = finfo_open(FILEINFO_MIME_TYPE);
+ $mime = finfo_file($finfo, $file);
+ finfo_close($finfo);
+ return $mime;
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns the MIME type of a file
+ *
+ * @param string $file
+ * @return string|false
+ */
+ public static function fromMimeContentType(string $file)
+ {
+ if (function_exists('mime_content_type') === true && file_exists($file) === true) {
+ return mime_content_type($file);
+ }
+
+ return false;
+ }
+
+ /**
+ * Tries to detect a valid SVG and returns the MIME type accordingly
+ *
+ * @param string $file
+ * @return string|false
+ */
+ public static function fromSvg(string $file)
+ {
+ if (file_exists($file) === true) {
+ libxml_use_internal_errors(true);
+
+ $svg = new SimpleXMLElement(file_get_contents($file));
+
+ if ($svg !== false && $svg->getName() === 'svg') {
+ return 'image/svg+xml';
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Tests if a given MIME type is matched by an `Accept` header
+ * pattern; returns true if the MIME type is contained at all
+ *
+ * @param string $mime
+ * @param string $pattern
+ * @return bool
+ */
+ public static function isAccepted(string $mime, string $pattern): bool
+ {
+ $accepted = Str::accepted($pattern);
+
+ foreach ($accepted as $m) {
+ if (static::matches($mime, $m['value']) === true) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Tests if a MIME wildcard pattern from an `Accept` header
+ * matches a given type
+ * @since 3.3.0
+ *
+ * @param string $test
+ * @param string $wildcard
+ * @return bool
+ */
+ public static function matches(string $test, string $wildcard): bool
+ {
+ return fnmatch($wildcard, $test, FNM_PATHNAME) === true;
+ }
+
+ /**
+ * Returns the extension for a given MIME type
+ *
+ * @param string|null $mime
+ * @return string|false
+ */
+ public static function toExtension(string $mime = null)
+ {
+ foreach (static::$types as $key => $value) {
+ if (is_array($value) === true && in_array($mime, $value) === true) {
+ return $key;
+ }
+
+ if ($value === $mime) {
+ return $key;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Returns all available extensions for a given MIME type
+ *
+ * @param string|null $mime
+ * @return array
+ */
+ public static function toExtensions(string $mime = null): array
+ {
+ $extensions = [];
+
+ foreach (static::$types as $key => $value) {
+ if (is_array($value) === true && in_array($mime, $value) === true) {
+ $extensions[] = $key;
+ continue;
+ }
+
+ if ($value === $mime) {
+ $extensions[] = $key;
+ continue;
+ }
+ }
+
+ return $extensions;
+ }
+
+ /**
+ * Returns the MIME type of a file
+ *
+ * @param string $file
+ * @param string $extension
+ * @return string|false
+ */
+ public static function type(string $file, string $extension = null)
+ {
+ // use the standard finfo extension
+ $mime = static::fromFileInfo($file);
+
+ // use the mime_content_type function
+ if ($mime === false) {
+ $mime = static::fromMimeContentType($file);
+ }
+
+ // get the extension or extract it from the filename
+ $extension = $extension ?? F::extension($file);
+
+ // try to guess the mime type at least
+ if ($mime === false) {
+ $mime = static::fromExtension($extension);
+ }
+
+ // fix broken mime detection
+ return static::fix($file, $mime, $extension);
+ }
+
+ /**
+ * Returns all detectable MIME types
+ *
+ * @return array
+ */
+ public static function types(): array
+ {
+ return static::$types;
+ }
+}
diff --git a/kirby/src/Toolkit/Obj.php b/kirby/src/Toolkit/Obj.php
new file mode 100755
index 0000000..bce4e9b
--- /dev/null
+++ b/kirby/src/Toolkit/Obj.php
@@ -0,0 +1,106 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Obj extends stdClass
+{
+ /**
+ * Constructor
+ *
+ * @param array $data
+ */
+ public function __construct(array $data = [])
+ {
+ foreach ($data as $key => $val) {
+ $this->$key = $val;
+ }
+ }
+
+ /**
+ * Magic getter
+ *
+ * @param string $property
+ * @param array $arguments
+ * @return mixed
+ */
+ public function __call(string $property, array $arguments)
+ {
+ return $this->$property ?? null;
+ }
+
+ /**
+ * Improved `var_dump` output
+ *
+ * @return array
+ */
+ public function __debugInfo(): array
+ {
+ return $this->toArray();
+ }
+
+ /**
+ * Magic property getter
+ *
+ * @param string $property
+ * @return mixed
+ */
+ public function __get(string $property)
+ {
+ return null;
+ }
+
+ /**
+ * Property Getter
+ *
+ * @param string $property
+ * @param mixed $fallback
+ * @return mixed
+ */
+ public function get(string $property, $fallback = null)
+ {
+ return $this->$property ?? $fallback;
+ }
+
+ /**
+ * Converts the object to an array
+ *
+ * @return 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
+ *
+ * @param mixed ...$arguments
+ * @return string
+ */
+ public function toJson(...$arguments): string
+ {
+ return json_encode($this->toArray(), ...$arguments);
+ }
+}
diff --git a/kirby/src/Toolkit/Pagination.php b/kirby/src/Toolkit/Pagination.php
new file mode 100755
index 0000000..bcb47f5
--- /dev/null
+++ b/kirby/src/Toolkit/Pagination.php
@@ -0,0 +1,502 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Pagination
+{
+ use Properties {
+ setProperties as protected baseSetProperties;
+ }
+
+ /**
+ * The current page
+ *
+ * @var int
+ */
+ protected $page;
+
+ /**
+ * Total number of items
+ *
+ * @var int
+ */
+ protected $total = 0;
+
+ /**
+ * The number of items per page
+ *
+ * @var int
+ */
+ protected $limit = 20;
+
+ /**
+ * Whether validation of the pagination page
+ * is enabled; will throw Exceptions if true
+ *
+ * @var bool
+ */
+ public static $validate = true;
+
+ /**
+ * Creates a new pagination object
+ * with the given parameters
+ *
+ * @param array $props
+ */
+ public function __construct(array $props = [])
+ {
+ $this->setProperties($props);
+ }
+
+ /**
+ * Creates a pagination instance for the given
+ * collection with a flexible argument api
+ *
+ * @param \Kirby\Toolkit\Collection $collection
+ * @param mixed ...$arguments
+ * @return self
+ */
+ public static function for(Collection $collection, ...$arguments)
+ {
+ $a = $arguments[0] ?? null;
+ $b = $arguments[1] ?? null;
+
+ $params = [];
+
+ 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
+ *
+ * @deprecated 3.3.0 Setter is no longer supported, use $pagination->clone()
+ * @return int
+ */
+ public function page(int $page = null): int
+ {
+ if ($page !== null) {
+ throw new Exception('$pagination->page() setter is no longer supported, use $pagination->clone()'); // @codeCoverageIgnore
+ }
+
+ return $this->page;
+ }
+
+ /**
+ * Getter for the total number of items
+ *
+ * @deprecated 3.3.0 Setter is no longer supported, use $pagination->clone()
+ * @return int
+ */
+ public function total(int $total = null): int
+ {
+ if ($total !== null) {
+ throw new Exception('$pagination->total() setter is no longer supported, use $pagination->clone()'); // @codeCoverageIgnore
+ }
+
+ return $this->total;
+ }
+
+ /**
+ * Getter for the number of items per page
+ *
+ * @deprecated 3.3.0 Setter is no longer supported, use $pagination->clone()
+ * @return int
+ */
+ public function limit(int $limit = null): int
+ {
+ if ($limit !== null) {
+ throw new Exception('$pagination->limit() setter is no longer supported, use $pagination->clone()'); // @codeCoverageIgnore
+ }
+
+ return $this->limit;
+ }
+
+ /**
+ * Returns the index of the first item on the page
+ *
+ * @return int
+ */
+ public function start(): int
+ {
+ $index = $this->page() - 1;
+
+ if ($index < 0) {
+ $index = 0;
+ }
+
+ return $index * $this->limit() + 1;
+ }
+
+ /**
+ * Returns the index of the last item on the page
+ *
+ * @return int
+ */
+ public function end(): int
+ {
+ $value = ($this->start() - 1) + $this->limit();
+
+ if ($value <= $this->total()) {
+ return $value;
+ }
+
+ return $this->total();
+ }
+
+ /**
+ * Returns the total number of pages
+ *
+ * @return int
+ */
+ public function pages(): int
+ {
+ if ($this->total() === 0) {
+ return 0;
+ }
+
+ return ceil($this->total() / $this->limit());
+ }
+
+ /**
+ * Returns the first page
+ *
+ * @return int
+ */
+ public function firstPage(): int
+ {
+ return $this->total() === 0 ? 0 : 1;
+ }
+
+ /**
+ * Returns the last page
+ *
+ * @return int
+ */
+ public function lastPage(): int
+ {
+ return $this->pages();
+ }
+
+ /**
+ * Returns the offset (i.e. for db queries)
+ *
+ * @return int
+ */
+ public function offset(): int
+ {
+ return $this->start() - 1;
+ }
+
+ /**
+ * Checks if the given page exists
+ *
+ * @param int $page
+ * @return bool
+ */
+ 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
+ *
+ * @return bool
+ */
+ public function hasPages(): bool
+ {
+ return $this->total() > $this->limit();
+ }
+
+ /**
+ * Checks if there's a previous page
+ *
+ * @return bool
+ */
+ public function hasPrevPage(): bool
+ {
+ return $this->page() > 1;
+ }
+
+ /**
+ * Returns the previous page
+ *
+ * @return int|null
+ */
+ public function prevPage()
+ {
+ return $this->hasPrevPage() ? $this->page() - 1 : null;
+ }
+
+ /**
+ * Checks if there's a next page
+ *
+ * @return bool
+ */
+ public function hasNextPage(): bool
+ {
+ return $this->end() < $this->total();
+ }
+
+ /**
+ * Returns the next page
+ *
+ * @return int|null
+ */
+ public function nextPage()
+ {
+ return $this->hasNextPage() ? $this->page() + 1 : null;
+ }
+
+ /**
+ * Checks if the current page is the first page
+ *
+ * @return bool
+ */
+ public function isFirstPage(): bool
+ {
+ return $this->page() === $this->firstPage();
+ }
+
+ /**
+ * Checks if the current page is the last page
+ *
+ * @return bool
+ */
+ public function isLastPage(): bool
+ {
+ return $this->page() === $this->lastPage();
+ }
+
+ /**
+ * Creates a range of page numbers for Google-like pagination
+ *
+ * @param int $range
+ * @return array
+ */
+ public function range(int $range = 5): array
+ {
+ $page = $this->page();
+ $pages = $this->pages();
+ $start = 1;
+ $end = $pages;
+
+ if ($pages <= $range) {
+ return range($start, $end);
+ }
+
+ $start = $page - (int)floor($range/2);
+ $end = $page + (int)floor($range/2);
+
+ if ($start <= 0) {
+ $end += abs($start);
+ $start = 1;
+ }
+
+ if ($end > $pages) {
+ $start -= $end - $pages;
+ $end = $pages;
+ }
+
+ return range($start, $end);
+ }
+
+ /**
+ * Returns the first page of the created range
+ *
+ * @param int $range
+ * @return int
+ */
+ public function rangeStart(int $range = 5): int
+ {
+ return $this->range($range)[0];
+ }
+
+ /**
+ * Returns the last page of the created range
+ *
+ * @param int $range
+ * @return int
+ */
+ public function rangeEnd(int $range = 5): int
+ {
+ $range = $this->range($range);
+ return array_pop($range);
+ }
+
+ /**
+ * Sets the properties limit, total and page
+ * and validates that the properties match
+ *
+ * @param array $props Array with keys limit, total and/or page
+ * @return self
+ */
+ protected function setProperties(array $props)
+ {
+ $this->baseSetProperties($props);
+
+ // ensure that page is set to something, otherwise
+ // generate "default page" based on other params
+ if ($this->page === null) {
+ $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);
+ } else {
+ $this->page = max(min($this->page, $max), $min);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Sets the number of items per page
+ *
+ * @param int $limit
+ * @return self
+ */
+ protected function setLimit(int $limit = 20)
+ {
+ if ($limit < 1) {
+ throw new Exception('Invalid pagination limit: ' . $limit);
+ }
+
+ $this->limit = $limit;
+ return $this;
+ }
+
+ /**
+ * Sets the total number of items
+ *
+ * @param int $total
+ * @return self
+ */
+ protected function setTotal(int $total = 0)
+ {
+ 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 self
+ */
+ protected function setPage($page = null)
+ {
+ // 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
+ *
+ * @return array
+ */
+ 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(),
+ ];
+ }
+}
diff --git a/kirby/src/Toolkit/Properties.php b/kirby/src/Toolkit/Properties.php
new file mode 100755
index 0000000..52c356b
--- /dev/null
+++ b/kirby/src/Toolkit/Properties.php
@@ -0,0 +1,151 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+trait Properties
+{
+ protected $propertyData = [];
+
+ /**
+ * Creates an instance with the same
+ * initial properties.
+ *
+ * @param array $props
+ * @return self
+ */
+ 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 self
+ */
+ 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 = $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]);
+ }
+ }
+}
diff --git a/kirby/src/Toolkit/Query.php b/kirby/src/Toolkit/Query.php
new file mode 100755
index 0000000..391f0f2
--- /dev/null
+++ b/kirby/src/Toolkit/Query.php
@@ -0,0 +1,203 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Query
+{
+ const PARTS = '!([a-zA-Z_]*(\(.*?\))?)\.|' . self::SKIP . '!';
+ const METHOD = '!\((.*)\)!';
+ const PARAMETERS = '!,|' . self::SKIP . '!';
+
+ const NO_PNTH = '\([^\(]+\)(*SKIP)(*FAIL)';
+ const NO_SQBR = '\[[^]]+\](*SKIP)(*FAIL)';
+ const NO_DLQU = '\"[^"]+\"(*SKIP)(*FAIL)';
+ const NO_SLQU = '\'[^\']+\'(*SKIP)(*FAIL)';
+ const SKIP = self::NO_PNTH . '|' . self::NO_SQBR . '|' .
+ self::NO_DLQU . '|' . self::NO_SLQU;
+
+ /**
+ * The query string
+ *
+ * @var string
+ */
+ protected $query;
+
+ /**
+ * Queryable data
+ *
+ * @var array
+ */
+ protected $data;
+
+ /**
+ * Creates a new Query object
+ *
+ * @param string $query
+ * @param array|object $data
+ */
+ public function __construct(string $query = null, $data = [])
+ {
+ $this->query = $query;
+ $this->data = $data;
+ }
+
+ /**
+ * Returns the query result if anything
+ * can be found. Otherwise returns null.
+ *
+ * @return mixed
+ */
+ public function result()
+ {
+ if (empty($this->query) === true) {
+ return $this->data;
+ }
+
+ return $this->resolve($this->query);
+ }
+
+ /**
+ * Resolves the query if anything
+ * can be found. Otherwise returns null.
+ *
+ * @param string $query
+ * @return mixed
+ */
+ protected function resolve(string $query)
+ {
+ // direct key access in arrays
+ if (is_array($this->data) === true && array_key_exists($query, $this->data) === true) {
+ return $this->data[$query];
+ }
+
+ $parts = $this->parts($query);
+ $data = $this->data;
+ $value = null;
+
+ while (count($parts)) {
+ $part = array_shift($parts);
+ $info = $this->part($part);
+ $method = $info['method'];
+ $value = null;
+
+ if (is_array($data)) {
+ $value = $data[$method] ?? null;
+ } elseif (is_object($data)) {
+ if (method_exists($data, $method) || method_exists($data, '__call')) {
+ $value = $data->$method(...$info['args']);
+ }
+ } elseif (is_scalar($data)) {
+ return $data;
+ } else {
+ return null;
+ }
+
+ if (is_array($value) || is_object($value)) {
+ $data = $value;
+ }
+ }
+
+ return $value;
+ }
+
+ /**
+ * Breaks the query string down into its components
+ *
+ * @param string $query
+ * @return array
+ */
+ protected function parts(string $query): array
+ {
+ $query = trim($query);
+
+ // match all parts but the last
+ preg_match_all(self::PARTS, $query, $match);
+
+ // remove all matched parts from the query to retrieve last part
+ foreach ($match[0] as $part) {
+ $query = Str::after($query, $part);
+ }
+
+ array_push($match[1], $query);
+ return $match[1];
+ }
+
+ /**
+ * Analyzes each part of the query string and
+ * extracts methods and method arguments.
+ *
+ * @param string $part
+ * @return array
+ */
+ protected function part(string $part): array
+ {
+ $args = [];
+ $method = preg_replace_callback(self::METHOD, function ($match) use (&$args) {
+ $args = preg_split(self::PARAMETERS, $match[1]);
+ $args = array_map('self::parameter', $args);
+ }, $part);
+
+ return [
+ 'method' => $method,
+ 'args' => $args
+ ];
+ }
+
+ /**
+ * Converts a parameter of query to
+ * proper type.
+ *
+ * @param mixed $arg
+ * @return mixed
+ */
+ protected function parameter($arg)
+ {
+ $arg = trim($arg);
+
+ // string with double quotes
+ if (substr($arg, 0, 1) === '"') {
+ return trim($arg, '"');
+ }
+
+ // string with single quotes
+ if (substr($arg, 0, 1) === '\'') {
+ return trim($arg, '\'');
+ }
+
+ // boolean or null
+ switch ($arg) {
+ case 'null':
+ return null;
+ case 'false':
+ return false;
+ case 'true':
+ return true;
+ }
+
+ // numeric
+ if (is_numeric($arg) === true) {
+ return (float)$arg;
+ }
+
+ // array: split and recursive sanitizing
+ if (substr($arg, 0, 1) === '[' && substr($arg, -1) === ']') {
+ $arg = substr($arg, 1, -1);
+ $arg = preg_split(self::PARAMETERS, $arg);
+ return array_map('self::parameter', $arg);
+ }
+
+ // resolve parameter for objects and methods itself
+ return $this->resolve($arg);
+ }
+}
diff --git a/kirby/src/Toolkit/Silo.php b/kirby/src/Toolkit/Silo.php
new file mode 100755
index 0000000..4158ea6
--- /dev/null
+++ b/kirby/src/Toolkit/Silo.php
@@ -0,0 +1,73 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Silo
+{
+ /**
+ * @var array
+ */
+ public static $data = [];
+
+ /**
+ * Setter for new data.
+ *
+ * @param string|array $key
+ * @param mixed $value
+ * @return array
+ */
+ public static function set($key, $value = null): array
+ {
+ if (is_array($key) === true) {
+ return static::$data = array_merge(static::$data, $key);
+ } else {
+ static::$data[$key] = $value;
+ return static::$data;
+ }
+ }
+
+ /**
+ * @param string|array $key
+ * @param mixed $default
+ * @return mixed
+ */
+ public static function get($key = null, $default = null)
+ {
+ if ($key === null) {
+ return static::$data;
+ }
+
+ return A::get(static::$data, $key, $default);
+ }
+
+ /**
+ * Removes an item from the data array
+ *
+ * @param string|null $key
+ * @return 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;
+ }
+}
diff --git a/kirby/src/Toolkit/Str.php b/kirby/src/Toolkit/Str.php
new file mode 100755
index 0000000..8ca6421
--- /dev/null
+++ b/kirby/src/Toolkit/Str.php
@@ -0,0 +1,1059 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Str
+{
+ /**
+ * Language translation table
+ *
+ * @var array
+ */
+ public static $language = [];
+
+ /**
+ * Ascii translation table
+ *
+ * @var array
+ */
+ public static $ascii = [
+ '/°|₀/' => '0',
+ '/¹|₁/' => '1',
+ '/²|₂/' => '2',
+ '/³|₃/' => '3',
+ '/⁴|₄/' => '4',
+ '/⁵|₅/' => '5',
+ '/⁶|₆/' => '6',
+ '/⁷|₇/' => '7',
+ '/⁸|₈/' => '8',
+ '/⁹|₉/' => '9',
+ '/À|Á|Â|Ã|Å|Ǻ|Ā|Ă|Ą|Ǎ|Ä|A/' => 'A',
+ '/à|á|â|ã|å|ǻ|ā|ă|ą|ǎ|ª|æ|ǽ|ä|a|а/' => 'a',
+ '/Б/' => 'B',
+ '/б/' => 'b',
+ '/Ç|Ć|Ĉ|Ċ|Č|Ц/' => 'C',
+ '/ç|ć|ĉ|ċ|č|ц/' => 'c',
+ '/Ð|Ď|Đ/' => 'Dj',
+ '/ð|ď|đ/' => 'dj',
+ '/Д/' => 'D',
+ '/д/' => 'd',
+ '/È|É|Ê|Ë|Ē|Ĕ|Ė|Ę|Ě|Е|Ё|Э/' => 'E',
+ '/è|é|ê|ë|ē|ĕ|ė|ę|ě|е|ё|э/' => 'e',
+ '/Ф/' => 'F',
+ '/ƒ|ф/' => 'f',
+ '/Ĝ|Ğ|Ġ|Ģ|Г/' => 'G',
+ '/ĝ|ğ|ġ|ģ|г/' => 'g',
+ '/Ĥ|Ħ|Х/' => 'H',
+ '/ĥ|ħ|х/' => 'h',
+ '/Ì|Í|Î|Ï|Ĩ|Ī|Ĭ|Ǐ|Į|İ|И/' => 'I',
+ '/ì|í|î|ï|ĩ|ī|ĭ|ǐ|į|ı|и|i̇/' => 'i',
+ '/Ĵ|Й/' => 'J',
+ '/ĵ|й/' => 'j',
+ '/Ķ|К/' => 'K',
+ '/ķ|к/' => 'k',
+ '/Ĺ|Ļ|Ľ|Ŀ|Ł|Л/' => 'L',
+ '/ĺ|ļ|ľ|ŀ|ł|л/' => 'l',
+ '/М/' => 'M',
+ '/м/' => 'm',
+ '/Ñ|Ń|Ņ|Ň|Н/' => 'N',
+ '/ñ|ń|ņ|ň|ʼn|н/' => 'n',
+ '/Ò|Ó|Ô|Õ|Ō|Ŏ|Ǒ|Ő|Ơ|Ø|Ǿ|Ö|O/' => 'O',
+ '/ò|ó|ô|õ|ō|ŏ|ǒ|ő|ơ|ø|ǿ|º|ö|o|о/' => 'o',
+ '/П/' => 'P',
+ '/п/' => 'p',
+ '/Ŕ|Ŗ|Ř|Р/' => 'R',
+ '/ŕ|ŗ|ř|р/' => 'r',
+ '/Ś|Ŝ|Ş|Ș|Š|С/' => 'S',
+ '/ś|ŝ|ş|ș|š|ſ|с/' => 's',
+ '/Ţ|Ț|Ť|Ŧ|Т/' => 'T',
+ '/ţ|ț|ť|ŧ|т/' => 't',
+ '/Ù|Ú|Û|Ũ|Ū|Ŭ|Ů|Ű|Ų|Ư|Ǔ|Ǖ|Ǘ|Ǚ|Ǜ|У|Ü|U/' => 'U',
+ '/ù|ú|û|ũ|ū|ŭ|ů|ű|ų|ư|ǔ|ǖ|ǘ|ǚ|ǜ|у|ü|u/' => 'u',
+ '/В/' => 'V',
+ '/в/' => 'v',
+ '/Ý|Ÿ|Ŷ|Ы/' => 'Y',
+ '/ý|ÿ|ŷ|ы/' => 'y',
+ '/Ŵ/' => 'W',
+ '/ŵ/' => 'w',
+ '/Ź|Ż|Ž|З/' => 'Z',
+ '/ź|ż|ž|з/' => 'z',
+ '/Æ|Ǽ/' => 'AE',
+ '/ß/' => 'ss',
+ '/IJ/' => 'IJ',
+ '/ij/' => 'ij',
+ '/Œ/' => 'OE',
+ '/Ч/' => 'Ch',
+ '/ч/' => 'ch',
+ '/Ю/' => 'Ju',
+ '/ю/' => 'ju',
+ '/Я/' => 'Ja',
+ '/я/' => 'ja',
+ '/Ш/' => 'Sh',
+ '/ш/' => 'sh',
+ '/Щ/' => 'Shch',
+ '/щ/' => 'shch',
+ '/Ж/' => 'Zh',
+ '/ж/' => 'zh',
+ ];
+
+ /**
+ * Default settings for class methods
+ *
+ * @var array
+ */
+ public static $defaults = [
+ 'slug' => [
+ 'separator' => '-',
+ 'allowed' => 'a-z0-9'
+ ]
+ ];
+
+ /**
+ * Parse accepted values and their quality from an
+ * accept string like an Accept or Accept-Language header
+ *
+ * @param string $input
+ * @return array
+ */
+ public static function accepted(string $input): array
+ {
+ $items = [];
+
+ // check each type in the Accept header
+ foreach (static::split($input, ',') as $item) {
+ $parts = static::split($item, ';');
+ $value = A::first($parts); // $parts now only contains params
+ $quality = 1;
+
+ // check for the q param ("quality" of the type)
+ foreach ($parts as $param) {
+ $param = static::split($param, '=');
+ if (A::get($param, 0) === 'q' && !empty($param[1])) {
+ $quality = $param[1];
+ }
+ }
+
+ $items[$quality][] = $value;
+ }
+
+ // sort items by quality
+ krsort($items);
+
+ $result = [];
+
+ foreach ($items as $quality => $values) {
+ foreach ($values as $value) {
+ $result[] = [
+ 'quality' => $quality,
+ 'value' => $value
+ ];
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Returns the rest of the string after the given character
+ *
+ * @param string $string
+ * @param string $needle
+ * @param bool $caseInsensitive
+ * @return string
+ */
+ public static function after(string $string, string $needle, bool $caseInsensitive = false): string
+ {
+ $position = static::position($string, $needle, $caseInsensitive);
+
+ if ($position === false) {
+ return false;
+ } else {
+ return static::substr($string, $position + static::length($needle));
+ }
+ }
+
+ /**
+ * Convert a string to 7-bit ASCII.
+ *
+ * @param string $string
+ * @return string
+ */
+ public static function ascii(string $string): string
+ {
+ $string = str_replace(
+ array_keys(static::$language),
+ array_values(static::$language),
+ $string
+ );
+
+ $string = preg_replace(
+ array_keys(static::$ascii),
+ array_values(static::$ascii),
+ $string
+ );
+
+ return preg_replace('/[^\x09\x0A\x0D\x20-\x7E]/', '', $string);
+ }
+
+ /**
+ * Returns the beginning of a string before the given character
+ *
+ * @param string $string
+ * @param string $needle
+ * @param bool $caseInsensitive
+ * @return string
+ */
+ public static function before(string $string, string $needle, bool $caseInsensitive = false): string
+ {
+ $position = static::position($string, $needle, $caseInsensitive);
+
+ if ($position === false) {
+ return false;
+ } else {
+ return static::substr($string, 0, $position);
+ }
+ }
+
+ /**
+ * Returns everything between two strings from the first occurrence of a given string
+ *
+ * @param string $string
+ * @param string $start
+ * @param string $end
+ * @return string
+ */
+ public static function between(string $string = null, string $start, string $end): string
+ {
+ return static::before(static::after($string, $start), $end);
+ }
+
+ /**
+ * Checks if a str contains another string
+ *
+ * @param string $string
+ * @param string $needle
+ * @param bool $caseInsensitive
+ * @return bool
+ */
+ public static function contains(string $string = null, string $needle, bool $caseInsensitive = false): bool
+ {
+ return call_user_func($caseInsensitive === true ? 'stristr' : 'strstr', $string, $needle) !== false;
+ }
+
+ /**
+ * Converts a string to a different encoding
+ *
+ * @param string $string
+ * @param string $targetEncoding
+ * @param string $sourceEncoding (optional)
+ * @return string
+ */
+ public static function convert($string, $targetEncoding, $sourceEncoding = null)
+ {
+ // detect the source encoding if not passed as third argument
+ if ($sourceEncoding === null) {
+ $sourceEncoding = static::encoding($string);
+ }
+
+ // no need to convert if the target encoding is the same
+ if (strtolower($sourceEncoding) === strtolower($targetEncoding)) {
+ return $string;
+ }
+
+ return iconv($sourceEncoding, $targetEncoding, $string);
+ }
+
+ /**
+ * Encode a string (used for email addresses)
+ *
+ * @param string $string
+ * @return string
+ */
+ public static function encode(string $string): string
+ {
+ $encoded = '';
+
+ for ($i = 0; $i < static::length($string); $i++) {
+ $char = static::substr($string, $i, 1);
+ list(, $code) = unpack('N', mb_convert_encoding($char, 'UCS-4BE', 'UTF-8'));
+ $encoded .= rand(1, 2) == 1 ? '' . $code . ';' : '' . dechex($code) . ';';
+ }
+
+ return $encoded;
+ }
+
+ /**
+ * Tries to detect the string encoding
+ *
+ * @param string $string
+ * @return string
+ */
+ public static function encoding(string $string): string
+ {
+ return mb_detect_encoding($string, 'UTF-8, ISO-8859-1, windows-1251', true);
+ }
+
+ /**
+ * Checks if a string ends with the passed needle
+ *
+ * @param string $string
+ * @param string $needle
+ * @param bool $caseInsensitive
+ * @return bool
+ */
+ public static function endsWith(string $string, string $needle, bool $caseInsensitive = false): bool
+ {
+ if ($needle === '') {
+ return true;
+ }
+
+ $probe = static::substr($string, -static::length($needle));
+
+ if ($caseInsensitive === true) {
+ $needle = static::lower($needle);
+ $probe = static::lower($probe);
+ }
+
+ return $needle === $probe;
+ }
+
+ /**
+ * Creates an excerpt of a string
+ * It removes all html tags first and then cuts the string
+ * according to the specified number of chars.
+ *
+ * @param string $string The string to be shortened
+ * @param int $chars The final number of characters the string should have
+ * @param bool $strip True: remove the HTML tags from the string first
+ * @param string $rep The element, which should be added if the string is too long. Ellipsis is the default.
+ * @return string The shortened string
+ */
+ public static function excerpt($string, $chars = 140, $strip = true, $rep = '…')
+ {
+ if ($strip === true) {
+ $string = strip_tags(str_replace('<', ' <', $string));
+ }
+
+ // replace line breaks with spaces
+ $string = str_replace(PHP_EOL, ' ', trim($string));
+
+ // remove double spaces
+ $string = preg_replace('![ ]{2,}!', ' ', $string);
+
+ if ($chars === 0) {
+ return $string;
+ }
+
+ if (static::length($string) <= $chars) {
+ return $string;
+ }
+
+ return static::substr($string, 0, strrpos(static::substr($string, 0, $chars), ' ')) . ' ' . $rep;
+ }
+
+ /**
+ * Convert the value to a float with a decimal
+ * point, no matter what the locale setting is
+ *
+ * @param string|int|float $value
+ * @return string
+ */
+ public static function float($value): string
+ {
+ $value = str_replace(',', '.', $value);
+ $decimal = strlen(substr(strrchr($value, '.'), 1));
+ return number_format((float)$value, $decimal, '.', false);
+ }
+
+ /**
+ * Returns the rest of the string starting from the given character
+ *
+ * @param string $string
+ * @param string $needle
+ * @param bool $caseInsensitive
+ * @return string
+ */
+ public static function from(string $string, string $needle, bool $caseInsensitive = false): string
+ {
+ $position = static::position($string, $needle, $caseInsensitive);
+
+ if ($position === false) {
+ return false;
+ } else {
+ return static::substr($string, $position);
+ }
+ }
+
+ /**
+ * Checks if the given string is a URL
+ *
+ * @param string|null $string
+ * @return bool
+ */
+ public static function isURL(string $string = null): bool
+ {
+ return filter_var($string, FILTER_VALIDATE_URL);
+ }
+
+ /**
+ * Convert a string to kebab case.
+ *
+ * @param string $value
+ * @return string
+ */
+ public static function kebab(string $value = null): string
+ {
+ return static::snake($value, '-');
+ }
+
+ /**
+ * A UTF-8 safe version of strlen()
+ *
+ * @param string $string
+ * @return int
+ */
+ public static function length(string $string = null): int
+ {
+ return mb_strlen($string, 'UTF-8');
+ }
+
+ /**
+ * A UTF-8 safe version of strtolower()
+ *
+ * @param string $string
+ * @return string
+ */
+ public static function lower(string $string = null): string
+ {
+ return mb_strtolower($string, 'UTF-8');
+ }
+
+ /**
+ * Safe ltrim alternative
+ *
+ * @param string $string
+ * @param string $trim
+ * @return string
+ */
+ public static function ltrim(string $string, string $trim = ' '): string
+ {
+ return preg_replace('!^(' . preg_quote($trim) . ')+!', '', $string);
+ }
+
+
+ /**
+ * Get a character pool with various possible combinations
+ *
+ * @param string|array $type
+ * @param bool $array
+ * @return string|array
+ */
+ public static function pool($type, bool $array = true)
+ {
+ $pool = [];
+
+ if (is_array($type) === true) {
+ foreach ($type as $t) {
+ $pool = array_merge($pool, static::pool($t));
+ }
+
+ return $pool;
+ } else {
+ switch ($type) {
+ case 'alphaLower':
+ $pool = range('a', 'z');
+ break;
+ case 'alphaUpper':
+ $pool = range('A', 'Z');
+ break;
+ case 'alpha':
+ $pool = static::pool(['alphaLower', 'alphaUpper']);
+ break;
+ case 'num':
+ $pool = range(0, 9);
+ break;
+ case 'alphaNum':
+ $pool = static::pool(['alpha', 'num']);
+ break;
+ }
+ }
+
+ return $array ? $pool : implode('', $pool);
+ }
+
+ /**
+ * Returns the position of a needle in a string
+ * if it can be found
+ *
+ * @param string $string
+ * @param string $needle
+ * @param bool $caseInsensitive
+ * @return int|bool
+ */
+ public static function position(string $string = null, string $needle, bool $caseInsensitive = false)
+ {
+ if ($caseInsensitive === true) {
+ $string = static::lower($string);
+ $needle = static::lower($needle);
+ }
+
+ return mb_strpos($string, $needle, 0, 'UTF-8');
+ }
+
+ /**
+ * Runs a string query.
+ * Check out the Query class for more information.
+ *
+ * @param string $query
+ * @param array $data
+ * @return string|null
+ */
+ public static function query(string $query, array $data = [])
+ {
+ return (new Query($query, $data))->result();
+ }
+
+ /**
+ * Generates a random string that may be used for cryptographic purposes
+ *
+ * @param int $length The length of the random string
+ * @param string $type Pool type (type of allowed characters)
+ * @return string
+ */
+ public static function random(int $length = null, string $type = 'alphaNum')
+ {
+ if ($length === null) {
+ $length = random_int(5, 10);
+ }
+
+ $pool = static::pool($type, false);
+
+ // catch invalid pools
+ if (!$pool) {
+ return false;
+ }
+
+ // regex that matches all characters *not* in the pool of allowed characters
+ $regex = '/[^' . $pool . ']/';
+
+ // collect characters until we have our required length
+ $result = '';
+
+ while (($currentLength = strlen($result)) < $length) {
+ $missing = $length - $currentLength;
+ $bytes = random_bytes($missing);
+ $result .= substr(preg_replace($regex, '', base64_encode($bytes)), 0, $missing);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Replaces all or some occurrences of the search string with the replacement string
+ * Extension of the str_replace() function in PHP with an additional $limit parameter
+ *
+ * @param string|array $string String being replaced on (haystack);
+ * can be an array of multiple subject strings
+ * @param string|array $search Value being searched for (needle)
+ * @param string|array $replace Value to replace matches with
+ * @param int|array $limit Maximum possible replacements for each search value;
+ * multiple limits for each search value are supported;
+ * defaults to no limit
+ * @return string|array String with replaced values;
+ * if $string is an array, array of strings
+ */
+ public static function replace($string, $search, $replace, $limit = -1)
+ {
+ // convert Kirby collections to arrays
+ if (is_a($string, 'Kirby\Toolkit\Collection') === true) {
+ $string = $string->toArray();
+ }
+
+ if (is_a($search, 'Kirby\Toolkit\Collection') === true) {
+ $search = $search->toArray();
+ }
+
+ if (is_a($replace, 'Kirby\Toolkit\Collection') === true) {
+ $replace = $replace->toArray();
+ }
+
+ // without a limit we might as well use the built-in function
+ if ($limit === -1) {
+ return str_replace($search, $replace, $string);
+ }
+
+ // if the limit is zero, the result will be no replacements at all
+ if ($limit === 0) {
+ return $string;
+ }
+
+ // multiple subjects are run separately through this method
+ if (is_array($string) === true) {
+ $result = [];
+ foreach ($string as $s) {
+ $result[] = static::replace($s, $search, $replace, $limit);
+ }
+ return $result;
+ }
+
+ // build an array of replacements
+ // we don't use an associative array because otherwise you couldn't
+ // replace the same string with different replacements
+ $replacements = static::replacements($search, $replace, $limit);
+
+ // run the string and the replacement array through the replacer
+ return static::replaceReplacements($string, $replacements);
+ }
+
+ /**
+ * Generates a replacement array out of dynamic input data
+ * Used for Str::replace()
+ *
+ * @param string|array $search Value being searched for (needle)
+ * @param string|array $replace Value to replace matches with
+ * @param int|array $limit Maximum possible replacements for each search value;
+ * multiple limits for each search value are supported;
+ * defaults to no limit
+ * @return array List of replacement arrays, each with a
+ * 'search', 'replace' and 'limit' attribute
+ */
+ public static function replacements($search, $replace, $limit): array
+ {
+ $replacements = [];
+
+ if (is_array($search) === true && is_array($replace) === true) {
+ foreach ($search as $i => $s) {
+ // replace with an empty string if no replacement string was defined for this index;
+ // behavior is identical to the official PHP str_replace()
+ $r = $replace[$i] ?? '';
+
+ if (is_array($limit) === true) {
+ // don't apply a limit if no limit was defined for this index
+ $l = $limit[$i] ?? -1;
+ } else {
+ $l = $limit;
+ }
+
+ $replacements[] = ['search' => $s, 'replace' => $r, 'limit' => $l];
+ }
+ } elseif (is_array($search) === true && is_string($replace) === true) {
+ foreach ($search as $i => $s) {
+ if (is_array($limit) === true) {
+ // don't apply a limit if no limit was defined for this index
+ $l = $limit[$i] ?? -1;
+ } else {
+ $l = $limit;
+ }
+
+ $replacements[] = ['search' => $s, 'replace' => $replace, 'limit' => $l];
+ }
+ } elseif (is_string($search) === true && is_string($replace) === true && is_int($limit) === true) {
+ $replacements[] = compact('search', 'replace', 'limit');
+ } else {
+ throw new Exception('Invalid combination of $search, $replace and $limit params.');
+ }
+
+ return $replacements;
+ }
+
+ /**
+ * Takes a replacement array and processes the replacements
+ * Used for Str::replace()
+ *
+ * @param string $string String being replaced on (haystack)
+ * @param array $replacements Replacement array from Str::replacements()
+ * @return string String with replaced values
+ */
+ public static function replaceReplacements(string $string, array $replacements): string
+ {
+ // replace in the order of the replacements
+ // behavior is identical to the official PHP str_replace()
+ foreach ($replacements as $replacement) {
+ if (is_int($replacement['limit']) === false) {
+ throw new Exception('Invalid limit "' . $replacement['limit'] . '".');
+ } elseif ($replacement['limit'] === -1) {
+
+ // no limit, we don't need our special replacement routine
+ $string = str_replace($replacement['search'], $replacement['replace'], $string);
+ } elseif ($replacement['limit'] > 0) {
+
+ // limit given, only replace for $replacement['limit'] times per replacement
+ $position = -1;
+
+ for ($i = 0; $i < $replacement['limit']; $i++) {
+ $position = strpos($string, $replacement['search'], $position + 1);
+
+ if (is_int($position) === true) {
+ $string = substr_replace($string, $replacement['replace'], $position, strlen($replacement['search']));
+ // adapt $pos to the now changed offset
+ $position = $position + strlen($replacement['replace']) - strlen($replacement['search']);
+ } else {
+ // no more match in the string
+ break;
+ }
+ }
+ }
+ }
+
+ return $string;
+ }
+
+ /**
+ * Safe rtrim alternative
+ *
+ * @param string $string
+ * @param string $trim
+ * @return string
+ */
+ public static function rtrim(string $string, string $trim = ' '): string
+ {
+ return preg_replace('!(' . preg_quote($trim) . ')+$!', '', $string);
+ }
+
+ /**
+ * Shortens a string and adds an ellipsis if the string is too long
+ *
+ *
+ *
+ * echo Str::short('This is a very, very, very long string', 10);
+ * // output: This is a…
+ *
+ * echo Str::short('This is a very, very, very long string', 10, '####');
+ * // output: This i####
+ *
+ *
+ *
+ * @param string $string The string to be shortened
+ * @param int $length The final number of characters the
+ * string should have
+ * @param string $appendix The element, which should be added if the
+ * string is too long. Ellipsis is the default.
+ * @return string The shortened string
+ */
+ public static function short(string $string = null, int $length = 0, string $appendix = '…'): ?string
+ {
+ if ($length === 0) {
+ return $string;
+ }
+
+ if (static::length($string) <= $length) {
+ return $string;
+ }
+
+ return static::substr($string, 0, $length) . $appendix;
+ }
+
+ /**
+ * Convert a string to a safe version to be used in a URL
+ *
+ * @param string $string The unsafe string
+ * @param string $separator To be used instead of space and
+ * other non-word characters.
+ * @param string $allowed List of all allowed characters (regex)
+ * @param int $maxlength The maximum length of the slug
+ * @return string The safe string
+ */
+ public static function slug(string $string = null, string $separator = null, string $allowed = null, int $maxlength = 128): string
+ {
+ $separator = $separator ?? static::$defaults['slug']['separator'];
+ $allowed = $allowed ?? static::$defaults['slug']['allowed'];
+
+ $string = trim($string);
+ $string = static::lower($string);
+ $string = static::ascii($string);
+
+ // replace spaces with simple dashes
+ $string = preg_replace('![^' . $allowed . ']!i', $separator, $string);
+
+ if (strlen($separator) > 0) {
+ // remove double separators
+ $string = preg_replace('![' . preg_quote($separator) . ']{2,}!', $separator, $string);
+ }
+
+ // replace slashes with dashes
+ $string = str_replace('/', $separator, $string);
+
+ // trim leading and trailing non-word-chars
+ $string = preg_replace('!^[^a-z0-9]+!', '', $string);
+ $string = preg_replace('![^a-z0-9]+$!', '', $string);
+
+ // cut the string after the given maxlength
+ return static::short($string, $maxlength, false);
+ }
+
+ /**
+ * Convert a string to snake case.
+ *
+ * @param string $value
+ * @param string $delimiter
+ * @return string
+ */
+ public static function snake(string $value = null, string $delimiter = '_'): string
+ {
+ if (!ctype_lower($value)) {
+ $value = preg_replace('/\s+/u', '', ucwords($value));
+ $value = static::lower(preg_replace('/(.)(?=[A-Z])/u', '$1' . $delimiter, $value));
+ }
+ return $value;
+ }
+
+ /**
+ * Better alternative for explode()
+ * It takes care of removing empty values
+ * and it has a built-in way to skip values
+ * which are too short.
+ *
+ * @param string $string The string to split
+ * @param string $separator The string to split by
+ * @param int $length The min length of values.
+ * @return array An array of found values
+ */
+ public static function split($string, string $separator = ',', int $length = 1): array
+ {
+ if (is_array($string) === true) {
+ return $string;
+ }
+
+ $parts = explode($separator, $string);
+ $out = [];
+
+ foreach ($parts as $p) {
+ $p = trim($p);
+ if (static::length($p) > 0 && static::length($p) >= $length) {
+ $out[] = $p;
+ }
+ }
+
+ return $out;
+ }
+
+ /**
+ * Checks if a string starts with the passed needle
+ *
+ * @param string $string
+ * @param string $needle
+ * @param bool $caseInsensitive
+ * @return bool
+ */
+ public static function startsWith(string $string = null, string $needle, bool $caseInsensitive = false): bool
+ {
+ if ($needle === '') {
+ return true;
+ }
+
+ return static::position($string, $needle, $caseInsensitive) === 0;
+ }
+
+ /**
+ * A UTF-8 safe version of substr()
+ *
+ * @param string $string
+ * @param int $start
+ * @param int $length
+ * @return string
+ */
+ public static function substr(string $string = null, int $start = 0, int $length = null): string
+ {
+ return mb_substr($string, $start, $length, 'UTF-8');
+ }
+
+ /**
+ * Replaces placeholders in string with value from array
+ *
+ *
+ *
+ * echo Str::template('From {{ b }} to {{ a }}', ['a' => 'there', 'b' => 'here']);
+ * // output: From here to there
+ *
+ *
+ *
+ * @param string $string The string with placeholders
+ * @param array $data Associative array with placeholders as
+ * keys and replacements as values
+ * @param string $fallback A fallback if a token does not have any matches
+ * @param string $start Placeholder start characters
+ * @param string $end Placeholder end characters
+ * @return string The filled-in string
+ */
+ public static function template(string $string = null, array $data = [], string $fallback = null, string $start = '{{', string $end = '}}'): string
+ {
+ return preg_replace_callback('!' . $start . '(.*?)' . $end . '!', function ($match) use ($data, $fallback) {
+ $query = trim($match[1]);
+ if (strpos($query, '.') !== false) {
+ return (new Query($match[1], $data))->result() ?? $fallback;
+ }
+ return $data[$query] ?? $fallback;
+ }, $string);
+ }
+
+ /**
+ * Converts a filesize string with shortcuts
+ * like M, G or K to an integer value
+ *
+ * @param mixed $size
+ * @return int
+ */
+ public static function toBytes($size): int
+ {
+ $size = trim($size);
+ $last = strtolower($size[strlen($size)-1] ?? null);
+ $size = (int)$size;
+
+ switch ($last) {
+ case 'g':
+ $size *= 1024;
+ // no break
+ case 'm':
+ $size *= 1024;
+ // no break
+ case 'k':
+ $size *= 1024;
+ }
+
+ return $size;
+ }
+
+ /**
+ * Convert the string to the given type
+ *
+ * @param string $string
+ * @param mixed $type
+ * @return mixed
+ */
+ public static function toType($string = null, $type)
+ {
+ if (is_string($type) === false) {
+ $type = gettype($type);
+ }
+
+ switch ($type) {
+ case 'array':
+ return (array)$string;
+ case 'bool':
+ case 'boolean':
+ return filter_var($string, FILTER_VALIDATE_BOOLEAN);
+ case 'double':
+ case 'float':
+ return (float)$string;
+ case 'int':
+ case 'integer':
+ return (int)$string;
+ }
+
+ return (string)$string;
+ }
+
+ /**
+ * Safe trim alternative
+ *
+ * @param string $string
+ * @param string $trim
+ * @return string
+ */
+ public static function trim(string $string, string $trim = ' '): string
+ {
+ return static::rtrim(static::ltrim($string, $trim), $trim);
+ }
+
+ /**
+ * A UTF-8 safe version of ucfirst()
+ *
+ * @param string $string
+ * @return string
+ */
+ public static function ucfirst(string $string = null): string
+ {
+ return static::upper(static::substr($string, 0, 1)) . static::lower(static::substr($string, 1));
+ }
+
+ /**
+ * A UTF-8 safe version of ucwords()
+ *
+ * @param string $string
+ * @return string
+ */
+ public static function ucwords(string $string = null): string
+ {
+ return mb_convert_case($string, MB_CASE_TITLE, 'UTF-8');
+ }
+
+ /**
+ * Removes all html tags and encoded chars from a string
+ *
+ *
+ *
+ * echo str::unhtml('some crazy stuff');
+ * // output: some uber crazy stuff
+ *
+ *
+ *
+ * @param string $string
+ * @return string The html string
+ */
+ public static function unhtml(string $string = null): string
+ {
+ return Html::decode($string);
+ }
+
+ /**
+ * Returns the beginning of a string until the given character
+ *
+ * @param string $string
+ * @param string $needle
+ * @param bool $caseInsensitive
+ * @return string
+ */
+ public static function until(string $string, string $needle, bool $caseInsensitive = false): string
+ {
+ $position = static::position($string, $needle, $caseInsensitive);
+
+ if ($position === false) {
+ return false;
+ } else {
+ return static::substr($string, 0, $position + static::length($needle));
+ }
+ }
+
+ /**
+ * A UTF-8 safe version of strotoupper()
+ *
+ * @param string $string
+ * @return string
+ */
+ public static function upper(string $string = null): string
+ {
+ return mb_strtoupper($string, 'UTF-8');
+ }
+
+ /**
+ * The widont function makes sure that there are no
+ * typographical widows at the end of a paragraph –
+ * that's a single word in the last line
+ *
+ * @param string $string
+ * @return string
+ */
+ public static function widont(string $string = null): string
+ {
+ return preg_replace_callback('|([^\s])\s+([^\s]+)\s*$|u', function ($matches) {
+ if (static::contains($matches[2], '-')) {
+ return $matches[1] . ' ' . str_replace('-', '‑', $matches[2]);
+ } else {
+ return $matches[1] . ' ' . $matches[2];
+ }
+ }, $string);
+ }
+}
diff --git a/kirby/src/Toolkit/Tpl.php b/kirby/src/Toolkit/Tpl.php
new file mode 100755
index 0000000..41816f3
--- /dev/null
+++ b/kirby/src/Toolkit/Tpl.php
@@ -0,0 +1,51 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Tpl
+{
+ /**
+ * Renders the template
+ *
+ * @param string $__file
+ * @param array $__data
+ * @return string
+ */
+ public static function load(string $__file = null, array $__data = []): string
+ {
+ if (file_exists($__file) === false) {
+ return '';
+ }
+
+ $exception = null;
+
+ ob_start();
+ extract($__data);
+
+ try {
+ require $__file;
+ } catch (Throwable $e) {
+ $exception = $e;
+ }
+
+ $content = ob_get_contents();
+ ob_end_clean();
+
+ if ($exception === null) {
+ return $content;
+ }
+
+ throw $exception;
+ }
+}
diff --git a/kirby/src/Toolkit/V.php b/kirby/src/Toolkit/V.php
new file mode 100755
index 0000000..37ba853
--- /dev/null
+++ b/kirby/src/Toolkit/V.php
@@ -0,0 +1,488 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class V
+{
+ /**
+ * An array with all installed validators
+ *
+ * @var array
+ */
+ public static $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
+ *
+ * @param mixed $input
+ * @param array $rules
+ * @param array $messages
+ * @return array
+ */
+ public static function errors($input, array $rules, $messages = []): array
+ {
+ $errors = static::value($input, $rules, $messages, false);
+
+ return $errors === true ? [] : $errors;
+ }
+
+ /**
+ * Creates a useful error message for the given validator
+ * and the arguments. This is used mainly internally
+ * to create error messages
+ *
+ * @param string $validatorName
+ * @param mixed ...$params
+ * @return string|null
+ */
+ public static function message(string $validatorName, ...$params): ?string
+ {
+ $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) {
+ try {
+ foreach ($value as $index => $item) {
+ if (is_array($item) === true) {
+ $value[$index] = implode('|', $item);
+ }
+ }
+ $value = implode(', ', $value);
+ } catch (Throwable $e) {
+ $value = '-';
+ }
+ }
+
+ $arguments[$parameter->getName()] = $value;
+ }
+
+ return I18n::template($translationKey, 'The "' . $validatorName . '" validation failed', $arguments);
+ }
+
+ /**
+ * Return the list of all validators
+ *
+ * @return array
+ */
+ public static function validators(): array
+ {
+ return static::$validators;
+ }
+
+ /**
+ * Validate a single value against
+ * a set of rules, using all registered
+ * validators
+ *
+ * @param mixed $value
+ * @param array $rules
+ * @param array $messages
+ * @param bool $fail
+ * @return bool|array
+ */
+ public static function value($value, array $rules, array $messages = [], bool $fail = true)
+ {
+ $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
+ *
+ * @param array $input
+ * @param array $rules
+ * @return bool
+ */
+ 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 {
+ V::value($fieldValue, $fieldRules);
+ } catch (Exception $e) {
+ throw new Exception(sprintf($e->getMessage() . ' for field "%s"', $fieldName));
+ }
+
+ static::value($fieldValue, $fieldRules);
+ }
+
+ return true;
+ }
+
+ /**
+ * Calls an installed validator and passes all arguments
+ *
+ * @param string $method
+ * @param array $arguments
+ * @return bool
+ */
+ 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 {
+ return V::match($value, '/^([a-z])+$/i') === true;
+ },
+
+ /**
+ * Valid: `a-z | A-Z | 0-9`
+ */
+ 'alphanum' => function ($value): bool {
+ return V::match($value, '/^[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 if the given string contains the given value
+ */
+ 'contains' => function ($value, $needle): bool {
+ return Str::contains($value, $needle);
+ },
+
+ /**
+ * Checks for a valid date
+ */
+ 'date' => function ($value): bool {
+ $date = date_parse($value);
+ return $date !== false &&
+ $date['error_count'] === 0 &&
+ $date['warning_count'] === 0;
+ },
+
+ /**
+ * 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 {
+ $parts = Str::split($value, '@');
+ $address = $parts[0] ?? null;
+ $domain = Idn::encode($parts[1] ?? '');
+ $email = $address . '@' . $domain;
+ } catch (Throwable $e) {
+ return false;
+ }
+
+ return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
+ }
+
+ return true;
+ },
+
+ /**
+ * 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 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, $value) !== 0;
+ },
+
+ /**
+ * 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 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 in the given array
+ */
+ 'required' => function ($key, array $array): bool {
+ return isset($array[$key]) === true &&
+ V::notIn($array[$key], [null, '', []]) === true;
+ },
+
+ /**
+ * 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 (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');
+ }
+
+ switch ($operator) {
+ case '<':
+ return $count < $size;
+ case '>':
+ return $count > $size;
+ case '<=':
+ return $count <= $size;
+ case '>=':
+ return $count >= $size;
+ default:
+ return $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 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;
+ }
+];
diff --git a/kirby/src/Toolkit/View.php b/kirby/src/Toolkit/View.php
new file mode 100755
index 0000000..db54591
--- /dev/null
+++ b/kirby/src/Toolkit/View.php
@@ -0,0 +1,138 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class View
+{
+ /**
+ * The absolute path to the view file
+ *
+ * @var string
+ */
+ protected $file;
+
+ /**
+ * The view data
+ *
+ * @var array
+ */
+ protected $data = [];
+
+ /**
+ * Creates a new view object
+ *
+ * @param string $file
+ * @param array $data
+ */
+ public function __construct(string $file, array $data = [])
+ {
+ $this->file = $file;
+ $this->data = $data;
+ }
+
+ /**
+ * Returns the view's data array
+ * without globals.
+ *
+ * @return array
+ */
+ public function data(): array
+ {
+ return $this->data;
+ }
+
+ /**
+ * Checks if the template file exists
+ *
+ * @return bool
+ */
+ public function exists(): bool
+ {
+ return file_exists($this->file()) === true;
+ }
+
+ /**
+ * Returns the view file
+ *
+ * @return string|false
+ */
+ public function file()
+ {
+ return $this->file;
+ }
+
+ /**
+ * Creates an error message for the missing view exception
+ *
+ * @return string
+ */
+ protected function missingViewMessage(): string
+ {
+ return 'The view does not exist: ' . $this->file();
+ }
+
+ /**
+ * Renders the view
+ *
+ * @return string
+ */
+ public function render(): string
+ {
+ if ($this->exists() === false) {
+ throw new Exception($this->missingViewMessage());
+ }
+
+ $exception = null;
+
+ ob_start();
+ extract($this->data());
+
+ try {
+ require $this->file();
+ } catch (Throwable $e) {
+ $exception = $e;
+ }
+
+ $content = ob_get_contents();
+ ob_end_clean();
+
+ if ($exception === null) {
+ return $content;
+ }
+
+ throw $exception;
+ }
+
+ /**
+ * Alias for View::render()
+ *
+ * @return string
+ */
+ public function toString(): string
+ {
+ return $this->render();
+ }
+
+ /**
+ * Magic string converter to enable
+ * converting view objects to string
+ *
+ * @return string
+ */
+ public function __toString(): string
+ {
+ return $this->render();
+ }
+}
diff --git a/kirby/src/Toolkit/Xml.php b/kirby/src/Toolkit/Xml.php
new file mode 100755
index 0000000..563f57e
--- /dev/null
+++ b/kirby/src/Toolkit/Xml.php
@@ -0,0 +1,246 @@
+
+ * @link https://getkirby.com
+ * @copyright Bastian Allgeier GmbH
+ * @license https://opensource.org/licenses/MIT
+ */
+class Xml
+{
+ /**
+ * Conversion table for html entities
+ *
+ * @var array
+ */
+ public static $entities = [
+ ' ' => ' ', '¡' => '¡', '¢' => '¢', '£' => '£', '¤' => '¤', '¥' => '¥', '¦' => '¦', '§' => '§',
+ '¨' => '¨', '©' => '©', 'ª' => 'ª', '«' => '«', '¬' => '¬', '' => '', '®' => '®', '¯' => '¯',
+ '°' => '°', '±' => '±', '²' => '²', '³' => '³', '´' => '´', 'µ' => 'µ', '¶' => '¶', '·' => '·',
+ '¸' => '¸', '¹' => '¹', 'º' => 'º', '»' => '»', '¼' => '¼', '½' => '½', '¾' => '¾', '¿' => '¿',
+ 'À' => 'À', 'Á' => 'Á', 'Â' => 'Â', 'Ã' => 'Ã', 'Ä' => 'Ä', 'Å' => 'Å', 'Æ' => 'Æ', 'Ç' => 'Ç',
+ 'È' => 'È', 'É' => 'É', 'Ê' => 'Ê', 'Ë' => 'Ë', 'Ì' => 'Ì', 'Í' => 'Í', 'Î' => 'Î', 'Ï' => 'Ï',
+ 'Ð' => 'Ð', 'Ñ' => 'Ñ', 'Ò' => 'Ò', 'Ó' => 'Ó', 'Ô' => 'Ô', 'Õ' => 'Õ', 'Ö' => 'Ö', '×' => '×',
+ 'Ø' => 'Ø', 'Ù' => 'Ù', 'Ú' => 'Ú', 'Û' => 'Û', 'Ü' => 'Ü', 'Ý' => 'Ý', 'Þ' => 'Þ', 'ß' => 'ß',
+ 'à' => 'à', 'á' => 'á', 'â' => 'â', 'ã' => 'ã', 'ä' => 'ä', 'å' => 'å', 'æ' => 'æ', 'ç' => 'ç',
+ 'è' => 'è', 'é' => 'é', 'ê' => 'ê', 'ë' => 'ë', 'ì' => 'ì', 'í' => 'í', 'î' => 'î', 'ï' => 'ï',
+ 'ð' => 'ð', 'ñ' => 'ñ', 'ò' => 'ò', 'ó' => 'ó', 'ô' => 'ô', 'õ' => 'õ', 'ö' => 'ö', '÷' => '÷',
+ 'ø' => 'ø', 'ù' => 'ù', 'ú' => 'ú', 'û' => 'û', 'ü' => 'ü', 'ý' => 'ý', 'þ' => 'þ', 'ÿ' => 'ÿ',
+ 'ƒ' => 'ƒ', 'Α' => 'Α', 'Β' => 'Β', 'Γ' => 'Γ', 'Δ' => 'Δ', 'Ε' => 'Ε', 'Ζ' => 'Ζ', 'Η' => 'Η',
+ 'Θ' => 'Θ', 'Ι' => 'Ι', 'Κ' => 'Κ', 'Λ' => 'Λ', 'Μ' => 'Μ', 'Ν' => 'Ν', 'Ξ' => 'Ξ', 'Ο' => 'Ο',
+ 'Π' => 'Π', 'Ρ' => 'Ρ', 'Σ' => 'Σ', 'Τ' => 'Τ', 'Υ' => 'Υ', 'Φ' => 'Φ', 'Χ' => 'Χ', 'Ψ' => 'Ψ',
+ 'Ω' => 'Ω', 'α' => 'α', 'β' => 'β', 'γ' => 'γ', 'δ' => 'δ', 'ε' => 'ε', 'ζ' => 'ζ', 'η' => 'η',
+ 'θ' => 'θ', 'ι' => 'ι', 'κ' => 'κ', 'λ' => 'λ', 'μ' => 'μ', 'ν' => 'ν', 'ξ' => 'ξ', 'ο' => 'ο',
+ 'π' => 'π', 'ρ' => 'ρ', 'ς' => 'ς', 'σ' => 'σ', 'τ' => 'τ', 'υ' => 'υ', 'φ' => 'φ', 'χ' => 'χ',
+ 'ψ' => 'ψ', 'ω' => 'ω', 'ϑ' => 'ϑ', 'ϒ' => 'ϒ', 'ϖ' => 'ϖ', '•' => '•', '…' => '…', '′' => '′',
+ '″' => '″', '‾' => '‾', '⁄' => '⁄', '℘' => '℘', 'ℑ' => 'ℑ', 'ℜ' => 'ℜ', '™' => '™', 'ℵ' => 'ℵ',
+ '←' => '←', '↑' => '↑', '→' => '→', '↓' => '↓', '↔' => '↔', '↵' => '↵', '⇐' => '⇐', '⇑' => '⇑',
+ '⇒' => '⇒', '⇓' => '⇓', '⇔' => '⇔', '∀' => '∀', '∂' => '∂', '∃' => '∃', '∅' => '∅', '∇' => '∇',
+ '∈' => '∈', '∉' => '∉', '∋' => '∋', '∏' => '∏', '∑' => '∑', '−' => '−', '∗' => '∗', '√' => '√',
+ '∝' => '∝', '∞' => '∞', '∠' => '∠', '∧' => '∧', '∨' => '∨', '∩' => '∩', '∪' => '∪', '∫' => '∫',
+ '∴' => '∴', '∼' => '∼', '≅' => '≅', '≈' => '≈', '≠' => '≠', '≡' => '≡', '≤' => '≤', '≥' => '≥',
+ '⊂' => '⊂', '⊃' => '⊃', '⊄' => '⊄', '⊆' => '⊆', '⊇' => '⊇', '⊕' => '⊕', '⊗' => '⊗', '⊥' => '⊥',
+ '⋅' => '⋅', '⌈' => '⌈', '⌉' => '⌉', '⌊' => '⌊', '⌋' => '⌋', '〈' => '〈', '〉' => '〉', '◊' => '◊',
+ '♠' => '♠', '♣' => '♣', '♥' => '♥', '♦' => '♦', '"' => '"', '&' => '&', '<' => '<', '>' => '>', 'Œ' => 'Œ',
+ 'œ' => 'œ', 'Š' => 'Š', 'š' => 'š', 'Ÿ' => 'Ÿ', 'ˆ' => 'ˆ', '˜' => '˜', ' ' => ' ', ' ' => ' ',
+ ' ' => ' ', '' => '', '' => '', '' => '', '' => '', '–' => '–', '—' => '—', '‘' => '‘',
+ '’' => '’', '‚' => '‚', '“' => '“', '”' => '”', '„' => '„', '†' => '†', '‡' => '‡', '‰' => '‰',
+ '‹' => '‹', '›' => '›', '€' => '€'
+ ];
+
+ /**
+ * Creates an XML string from an array
+ *
+ * @param string $props The source array
+ * @param string $name The name of the root element
+ * @param bool $head Include the xml declaration head or not
+ * @param int $level The indendation level
+ * @return string The XML string
+ */
+ public static function create($props, string $name = 'root', bool $head = true, $level = 0): string
+ {
+ $attributes = $props['@attributes'] ?? null;
+ $value = $props['@value'] ?? null;
+ $children = $props;
+ $indent = str_repeat(' ', $level);
+ $nextLevel = $level + 1;
+
+ if (is_array($children) === true) {
+ unset($children['@attributes'], $children['@value']);
+
+ $childTags = [];
+
+ foreach ($children as $childName => $childItems) {
+ if (is_array($childItems) === true) {
+
+ // another tag with attributes
+ if (A::isAssociative($childItems) === true) {
+ $childTags[] = static::create($childItems, $childName, false, $level);
+
+ // just children
+ } else {
+ foreach ($childItems as $childItem) {
+ $childTags[] = static::create($childItem, $childName, false, $nextLevel);
+ }
+ }
+ } else {
+ $childTags[] = static::tag($childName, $childItems, null, $indent);
+ }
+ }
+
+ if (empty($childTags) === false) {
+ $value = $childTags;
+ }
+ }
+
+ $result = $head === true ? '' . PHP_EOL : null;
+ $result .= static::tag($name, $value, $attributes, $indent);
+
+ return $result;
+ }
+
+ /**
+ * Removes all xml entities from a string
+ * and convert them to html entities first
+ * and remove all html entities afterwards.
+ *
+ *
+ *
+ * echo xml::decode('some über crazy stuff');
+ * // output: some über crazy stuff
+ *
+ *
+ *
+ * @param string $string
+ * @return string
+ */
+ public static function decode(string $string = null): string
+ {
+ return Html::decode($string);
+ }
+
+ /**
+ * Converts a string to a xml-safe string
+ * Converts it to html-safe first and then it
+ * will replace html entities to xml entities
+ *
+ *
+ *
+ * echo xml::encode('some über crazy stuff');
+ * // output: some über crazy stuff
+ *
+ *
+ *
+ * @param string $string
+ * @param bool $html True: convert to html first
+ * @return string
+ */
+ public static function encode(string $string = null, bool $html = true): string
+ {
+ if ($html === true) {
+ $string = Html::encode($string, false);
+ }
+
+ $entities = static::entities();
+ $searches = array_keys($entities);
+ $values = array_values($entities);
+
+ return str_replace($searches, $values, $string);
+ }
+
+ /**
+ * Returns the html to xml entities translation table
+ *
+ * @return array
+ */
+ public static function entities(): array
+ {
+ return static::$entities;
+ }
+
+ /**
+ * Parses a XML string and returns an array
+ *
+ * @param string $xml
+ * @return array|false
+ */
+ public static function parse(string $xml = null)
+ {
+ $xml = preg_replace('/(<\/?)(\w+):([^>]*>)/', '$1$2$3', $xml);
+ $xml = @simplexml_load_string($xml, null, LIBXML_NOENT | LIBXML_NOCDATA);
+
+ $xml = @json_encode($xml);
+ $xml = @json_decode($xml, true);
+ return is_array($xml) === true ? $xml : false;
+ }
+
+ /**
+ * Builds an XML tag
+ *
+ * @param string $name
+ * @param mixed $content
+ * @param array $attr
+ * @param mixed $indent
+ * @return string
+ */
+ public static function tag(string $name, $content = null, array $attr = null, $indent = null): string
+ {
+ $attr = Html::attr($attr);
+ $start = '<' . $name . ($attr ? ' ' . $attr : null) . '>';
+ $end = '' . $name . '>';
+
+ if (is_array($content) === true) {
+ $xml = $indent . $start . PHP_EOL;
+ foreach ($content as $line) {
+ $xml .= $indent . $indent . $line . PHP_EOL;
+ }
+ $xml .= $indent . $end;
+ } else {
+ $xml = $indent . $start . static::value($content) . $end;
+ }
+
+ return $xml;
+ }
+
+ /**
+ * Encodes the value as cdata if necessary
+ *
+ * @param mixed $value
+ * @return mixed
+ */
+ public static function value($value)
+ {
+ if ($value === true) {
+ return 'true';
+ }
+
+ if ($value === false) {
+ return 'false';
+ }
+
+ if (is_numeric($value) === true) {
+ return $value;
+ }
+
+ if ($value === null || $value === '') {
+ return null;
+ }
+
+ if (Str::contains($value, '';
+ }
+}
diff --git a/kirby/vendor/autoload.php b/kirby/vendor/autoload.php
new file mode 100755
index 0000000..e15730a
--- /dev/null
+++ b/kirby/vendor/autoload.php
@@ -0,0 +1,7 @@
+.
+//
+// Copyright A Beautiful Site, LLC.
+//
+// Source: https://github.com/claviska/SimpleImage
+//
+// Licensed under the MIT license