xiaowang/kirby/src/Cms/Blueprint.php
2022-03-22 15:39:39 +01:00

816 lines
22 KiB
PHP

<?php
namespace Kirby\Cms;
use Exception;
use Kirby\Data\Data;
use Kirby\Exception\InvalidArgumentException;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\F;
use Kirby\Form\Field;
use Kirby\Toolkit\A;
use Kirby\Toolkit\I18n;
use Throwable;
/**
* The Blueprint class normalizes an array from a
* blueprint file and converts sections, columns, fields
* etc. into a correct tab layout.
*
* @package Kirby Cms
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @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|null $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
* @throws \Kirby\Exception\InvalidArgumentException If the blueprint model is missing
*/
public function __construct(array $props)
{
if (empty($props['model']) === true) {
throw new InvalidArgumentException('A blueprint model is required');
}
if (is_a($props['model'], ModelWithContent::class) === false) {
throw new InvalidArgumentException('Invalid blueprint model');
}
$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'] ??= '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;
}
try {
$mixin = static::find($extends);
$mixin = static::extend($mixin);
$props = A::merge($mixin, $props, A::MERGE_REPLACE);
} catch (Exception $e) {
// keep the props unextended if the snippet wasn't found
}
// remove the extends flag
unset($props['extends']);
return $props;
}
/**
* Create a new blueprint for a model
*
* @param string $name
* @param string|null $fallback
* @param \Kirby\Cms\Model $model
* @return static|null
*/
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 array
* @throws \Kirby\Exception\NotFoundException If the blueprint cannot be found
*/
public static function find(string $name): array
{
if (isset(static::$loaded[$name]) === true) {
return static::$loaded[$name];
}
$kirby = App::instance();
$root = $kirby->root('blueprints');
$file = $root . '/' . $name . '.yml';
// first try to find a site blueprint,
// then check in the plugin extensions
if (F::exists($file, $root) !== true) {
$file = $kirby->extension('blueprints', $name);
}
// now ensure that we always return the data array
if (is_string($file) === true && F::exists($file) === true) {
return static::$loaded[$name] = Data::read($file);
} elseif (is_array($file) === true) {
return static::$loaded[$name] = $file;
} elseif (is_callable($file) === true) {
return static::$loaded[$name] = $file($kirby);
}
// neither a valid file nor array data
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
{
$props = static::find($name);
$normalize = function ($props) use ($name) {
// inject the filename as name if no name is set
$props['name'] ??= $name;
// normalize the title
$title = $props['title'] ?? ucfirst($props['name']);
// translate the title
$props['title'] = I18n::translate($title, $title);
return $props;
};
return $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) {
// unset/remove column if its property is not array
if (is_array($columnProps) === false) {
unset($columns[$columnKey]);
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;
}
/**
* @param array $items
* @return string
*/
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
* @throws \Kirby\Exception\InvalidArgumentException If the filed name is missing or the field type is invalid
*/
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 property 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(fn () => false, $defaults);
}
// extend options if possible
$options = $this->extend($options);
foreach ($options as $key => $value) {
$alias = $aliases[$key] ?? null;
if ($alias !== null) {
$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 property 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 <strong>"' . $fieldName . '"</strong> 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 property 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)),
'link' => $this->model->panel()->url(true) . '/?tab=' . $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;
}
$preset = static::$presets[$props['preset']];
if (is_string($preset) === true) {
$preset = require $preset;
}
return $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 A::map(
$this->sections,
fn ($section) => $this->section($section['name'])
);
}
/**
* Returns a single tab by name
*
* @param string|null $name
* @return array|null
*/
public function tab(?string $name = null): ?array
{
if ($name === null) {
return A::first($this->tabs);
}
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;
}
}