Initial commit

This commit is contained in:
isUnknown 2024-07-10 16:10:33 +02:00
commit 08a8a71c55
631 changed files with 139902 additions and 0 deletions

View file

@ -0,0 +1,134 @@
<?php
namespace Kirby\Panel\Lab;
use Kirby\Cms\App;
use Kirby\Filesystem\Dir;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
/**
* Category of lab examples located in
* `kirby/panel/lab` and `site/lab`.
*
* @internal
* @since 4.0.0
* @codeCoverageIgnore
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Category
{
protected string $root;
public function __construct(
protected string $id,
string|null $root = null,
protected array $props = []
) {
$this->root = $root ?? static::base() . '/' . $this->id;
if (file_exists($this->root . '/index.php') === true) {
$this->props = array_merge(
require $this->root . '/index.php',
$this->props
);
}
}
public static function all(): array
{
// all core lab examples from `kirby/panel/lab`
$examples = A::map(
Dir::inventory(static::base())['children'],
fn ($props) => (new static($props['dirname']))->toArray()
);
// all custom lab examples from `site/lab`
$custom = static::factory('site')->toArray();
array_push($examples, $custom);
return $examples;
}
public static function base(): string
{
return App::instance()->root('panel') . '/lab';
}
public function example(string $id, string|null $tab = null): Example
{
return new Example(parent: $this, id: $id, tab: $tab);
}
public function examples(): array
{
return A::map(
Dir::inventory($this->root)['children'],
fn ($props) => $this->example($props['dirname'])->toArray()
);
}
public static function factory(string $id)
{
return match ($id) {
'site' => static::site(),
default => new static($id)
};
}
public function icon(): string
{
return $this->props['icon'] ?? 'palette';
}
public function id(): string
{
return $this->id;
}
public static function installed(): bool
{
return Dir::exists(static::base()) === true;
}
public function name(): string
{
return $this->props['name'] ?? ucfirst($this->id);
}
public function root(): string
{
return $this->root;
}
public static function site(): static
{
return new static(
'site',
App::instance()->root('site') . '/lab',
[
'name' => 'Your examples',
'icon' => 'live'
]
);
}
public function toArray(): array
{
return [
'name' => $this->name(),
'examples' => $this->examples(),
'icon' => $this->icon(),
'path' => Str::after(
$this->root(),
App::instance()->root('index')
),
];
}
}

View file

@ -0,0 +1,340 @@
<?php
namespace Kirby\Panel\Lab;
use Kirby\Cms\App;
use Kirby\Data\Data;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
use Kirby\Toolkit\A;
use Kirby\Toolkit\Str;
/**
* Docs for a single Vue component
*
* @internal
* @since 4.0.0
* @codeCoverageIgnore
*
* @package Kirby Panel
* @author Nico Hoffmann <nico@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Docs
{
protected array $json;
protected App $kirby;
public function __construct(
protected string $name
) {
$this->kirby = App::instance();
$this->json = $this->read();
}
public static function all(): array
{
$dist = static::root();
$tmp = static::root(true);
$files = Dir::inventory($dist)['files'];
if (Dir::exists($tmp) === true) {
$files = [...Dir::inventory($tmp)['files'], ...$files];
}
$docs = A::map(
$files,
function ($file) {
$component = 'k-' . Str::camelToKebab(F::name($file['filename']));
return [
'image' => [
'icon' => 'book',
'back' => 'white',
],
'text' => $component,
'link' => '/lab/docs/' . $component,
];
}
);
usort($docs, fn ($a, $b) => $a['text'] <=> $b['text']);
return array_values($docs);
}
public function deprecated(): string|null
{
return $this->kt($this->json['tags']['deprecated'][0]['description'] ?? '');
}
public function description(): string
{
return $this->kt($this->json['description'] ?? '');
}
public function docBlock(): string
{
return $this->kt($this->json['docsBlocks'][0] ?? '');
}
public function events(): array
{
$events = A::map(
$this->json['events'] ?? [],
fn ($event) => [
'name' => $event['name'],
'description' => $this->kt($event['description'] ?? ''),
'deprecated' => $this->kt($event['tags']['deprecated'][0]['description'] ?? ''),
'since' => $event['tags']['since'][0]['description'] ?? null,
'properties' => A::map(
$event['properties'] ?? [],
fn ($property) => [
'name' => $property['name'],
'type' => $property['type']['names'][0] ?? '',
'description' => $this->kt($property['description'] ?? '', true),
]
),
]
);
usort($events, fn ($a, $b) => $a['name'] <=> $b['name']);
return $events;
}
public function examples(): array
{
if (empty($this->json['tags']['examples']) === false) {
return $this->json['tags']['examples'];
}
return [];
}
public function file(string $context): string
{
$root = match ($context) {
'dev' => $this->kirby->root('panel') . '/tmp',
'dist' => $this->kirby->root('panel') . '/dist/ui',
};
$name = Str::after($this->name, 'k-');
$name = Str::kebabToCamel($name);
return $root . '/' . $name . '.json';
}
public function github(): string
{
return 'https://github.com/getkirby/kirby/tree/main/panel/' . $this->json['sourceFile'];
}
public static function installed(): bool
{
return Dir::exists(static::root()) === true;
}
protected function kt(string $text, bool $inline = false): string
{
return $this->kirby->kirbytext($text, [
'markdown' => [
'breaks' => false,
'inline' => $inline,
]
]);
}
public function lab(): string|null
{
$root = $this->kirby->root('panel') . '/lab';
foreach (glob($root . '/{,*/,*/*/,*/*/*/}index.php', GLOB_BRACE) as $example) {
$props = require $example;
if (($props['docs'] ?? null) === $this->name) {
return Str::before(Str::after($example, $root), 'index.php');
}
}
return null;
}
public function methods(): array
{
$methods = A::map(
$this->json['methods'] ?? [],
fn ($method) => [
'name' => $method['name'],
'description' => $this->kt($method['description'] ?? ''),
'deprecated' => $this->kt($method['tags']['deprecated'][0]['description'] ?? ''),
'since' => $method['tags']['since'][0]['description'] ?? null,
'params' => A::map(
$method['params'] ?? [],
fn ($param) => [
'name' => $param['name'],
'type' => $param['type']['name'] ?? '',
'description' => $this->kt($param['description'] ?? '', true),
]
),
'returns' => $method['returns']['type']['name'] ?? null,
]
);
usort($methods, fn ($a, $b) => $a['name'] <=> $b['name']);
return $methods;
}
public function name(): string
{
return $this->name;
}
public function prop(string|int $key): array|null
{
$prop = $this->json['props'][$key];
// filter private props
if (($prop['tags']['access'][0]['description'] ?? null) === 'private') {
return null;
}
// filter unset props
if (($type = $prop['type']['name'] ?? null) === 'null') {
return null;
}
$default = $prop['defaultValue']['value'] ?? null;
$deprecated = $this->kt($prop['tags']['deprecated'][0]['description'] ?? '');
return [
'name' => Str::camelToKebab($prop['name']),
'type' => $type,
'description' => $this->kt($prop['description'] ?? ''),
'default' => $this->propDefault($default, $type),
'deprecated' => $deprecated,
'example' => $prop['tags']['example'][0]['description'] ?? null,
'required' => $prop['required'] ?? false,
'since' => $prop['tags']['since'][0]['description'] ?? null,
'value' => $prop['tags']['value'][0]['description'] ?? null,
'values' => $prop['values'] ?? null,
];
}
protected function propDefault(
string|null $default,
string|null $type
): string|null {
if ($default !== null) {
// normalize longform function
if (preg_match('/function\(\) {.*return (.*);.*}/si', $default, $matches) === 1) {
return $matches[1];
}
// normalize object shorthand function
if (preg_match('/\(\) => \((.*)\)/si', $default, $matches) === 1) {
return $matches[1];
}
// normalize all other defaults from shorthand function
if (preg_match('/\(\) => (.*)/si', $default, $matches) === 1) {
return $matches[1];
}
return $default;
}
// if type is boolean primarily and no default
// value has been set, add `false` as default
// for clarity
if (Str::startsWith($type, 'boolean')) {
return 'false';
}
return null;
}
public function props(): array
{
$props = A::map(
array_keys($this->json['props'] ?? []),
fn ($key) => $this->prop($key)
);
// remove empty props
$props = array_filter($props);
usort($props, fn ($a, $b) => $a['name'] <=> $b['name']);
// always return an array
return array_values($props);
}
protected function read(): array
{
$file = $this->file('dev');
if (file_exists($file) === false) {
$file = $this->file('dist');
}
return Data::read($file);
}
public static function root(bool $tmp = false): string
{
return App::instance()->root('panel') . '/' . match ($tmp) {
true => 'tmp',
default => 'dist/ui',
};
}
public function since(): string|null
{
return $this->json['tags']['since'][0]['description'] ?? null;
}
public function slots(): array
{
$slots = A::map(
$this->json['slots'] ?? [],
fn ($slot) => [
'name' => $slot['name'],
'description' => $this->kt($slot['description'] ?? ''),
'deprecated' => $this->kt($slot['tags']['deprecated'][0]['description'] ?? ''),
'since' => $slot['tags']['since'][0]['description'] ?? null,
'bindings' => A::map(
$slot['bindings'] ?? [],
fn ($binding) => [
'name' => $binding['name'],
'type' => $binding['type']['name'] ?? '',
'description' => $this->kt($binding['description'] ?? '', true),
]
),
]
);
usort($slots, fn ($a, $b) => $a['name'] <=> $b['name']);
return $slots;
}
public function toArray(): array
{
return [
'component' => $this->name(),
'deprecated' => $this->deprecated(),
'description' => $this->description(),
'docBlock' => $this->docBlock(),
'events' => $this->events(),
'examples' => $this->examples(),
'github' => $this->github(),
'methods' => $this->methods(),
'props' => $this->props(),
'since' => $this->since(),
'slots' => $this->slots(),
];
}
}

View file

@ -0,0 +1,296 @@
<?php
namespace Kirby\Panel\Lab;
use Kirby\Exception\NotFoundException;
use Kirby\Filesystem\Dir;
use Kirby\Filesystem\F;
use Kirby\Http\Response;
/**
* One or multiple lab examples with one or multiple tabs
*
* @internal
* @since 4.0.0
* @codeCoverageIgnore
*
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Example
{
protected string $root;
protected string|null $tab = null;
protected array $tabs;
public function __construct(
protected Category $parent,
protected string $id,
string|null $tab = null,
) {
$this->root = $this->parent->root() . '/' . $this->id;
if ($this->exists() === false) {
throw new NotFoundException('The example could not be found');
}
$this->tabs = $this->collectTabs();
$this->tab = $this->collectTab($tab);
}
public function collectTab(string|null $tab): string|null
{
if (empty($this->tabs) === true) {
return null;
}
if (array_key_exists($tab, $this->tabs) === true) {
return $tab;
}
return array_key_first($this->tabs);
}
public function collectTabs(): array
{
$tabs = [];
foreach (Dir::inventory($this->root)['children'] as $child) {
$tabs[$child['dirname']] = [
'name' => $child['dirname'],
'label' => $child['slug'],
'link' => '/lab/' . $this->parent->id() . '/' . $this->id . '/' . $child['dirname']
];
}
return $tabs;
}
public function exists(): bool
{
return is_dir($this->root) === true;
}
public function file(string $filename): string
{
return $this->parent->root() . '/' . $this->path() . '/' . $filename;
}
public function id(): string
{
return $this->id;
}
public function load(string $filename): array|null
{
if ($file = $this->file($filename)) {
return F::load($file);
}
return null;
}
public function module(): string
{
return $this->url() . '/index.vue';
}
public function path(): string
{
return match ($this->tab) {
null => $this->id,
default => $this->id . '/' . $this->tab
};
}
public function props(): array
{
if ($this->tab !== null) {
$props = $this->load('../index.php');
}
return array_replace_recursive(
$props ?? [],
$this->load('index.php') ?? []
);
}
public function read(string $filename): string|null
{
$file = $this->file($filename);
if (is_file($file) === false) {
return null;
}
return F::read($file);
}
public function root(): string
{
return $this->root;
}
public function serve(): Response
{
return new Response($this->vue()['script'], 'application/javascript');
}
public function tab(): string|null
{
return $this->tab;
}
public function tabs(): array
{
return $this->tabs;
}
public function template(string $filename): string|null
{
$file = $this->file($filename);
if (is_file($file) === false) {
return null;
}
$data = $this->props();
return (new Template($file))->render($data);
}
public function title(): string
{
return basename($this->id);
}
public function toArray(): array
{
return [
'image' => [
'icon' => $this->parent->icon(),
'back' => 'white',
],
'text' => $this->title(),
'link' => $this->url()
];
}
public function url(): string
{
return '/lab/' . $this->parent->id() . '/' . $this->path();
}
public function vue(): array
{
// read the index.vue file (or programmabel Vue PHP file)
$file = $this->read('index.vue');
$file ??= $this->template('index.vue.php');
$file ??= '';
// extract parts
$parts['script'] = $this->vueScript($file);
$parts['template'] = $this->vueTemplate($file);
$parts['examples'] = $this->vueExamples($parts['template'], $parts['script']);
$parts['style'] = $this->vueStyle($file);
return $parts;
}
public function vueExamples(string|null $template, string|null $script): array
{
$template ??= '';
$examples = [];
$scripts = [];
if (preg_match_all('!\/\*\* \@script: (.*?)\*\/(.*?)\/\*\* \@script-end \*\/!s', $script, $matches)) {
foreach ($matches[1] as $key => $name) {
$code = $matches[2][$key];
$code = preg_replace('!const (.*?) \=!', 'default', $code);
$scripts[trim($name)] = $code;
}
}
if (preg_match_all('!<k-lab-example[\s|\n].*?label="(.*?)"(.*?)>(.*?)<\/k-lab-example>!s', $template, $matches)) {
foreach ($matches[1] as $key => $name) {
$tail = $matches[2][$key];
$code = $matches[3][$key];
$scriptId = trim(preg_replace_callback(
'!script="(.*?)"!',
fn ($match) => trim($match[1]),
$tail
));
$scriptBlock = $scripts[$scriptId] ?? null;
if (empty($scriptBlock) === false) {
$js = PHP_EOL . PHP_EOL;
$js .= '<script>';
$js .= $scriptBlock;
$js .= '</script>';
} else {
$js = '';
}
// only use the code between the @code and @code-end comments
if (preg_match('$<!-- @code -->(.*?)<!-- @code-end -->$s', $code, $match)) {
$code = $match[1];
}
if (preg_match_all('/^(\t*)\S/m', $code, $indents)) {
// get minimum indent
$indents = array_map(fn ($i) => strlen($i), $indents[1]);
$indents = min($indents);
if (empty($js) === false) {
$indents--;
}
// strip minimum indent from each line
$code = preg_replace('/^\t{' . $indents . '}/m', '', $code);
}
$code = trim($code);
if (empty($js) === false) {
$code = '<template>' . PHP_EOL . "\t" . $code . PHP_EOL . '</template>';
}
$examples[$name] = $code . $js;
}
}
return $examples;
}
public function vueScript(string $file): string
{
if (preg_match('!<script>(.*)</script>!s', $file, $match)) {
return trim($match[1]);
}
return 'export default {}';
}
public function vueStyle(string $file): string|null
{
if (preg_match('!<style>(.*)</style>!s', $file, $match)) {
return trim($match[1]);
}
return null;
}
public function vueTemplate(string $file): string|null
{
if (preg_match('!<template>(.*)</template>!s', $file, $match)) {
return preg_replace('!^\n!', '', $match[1]);
}
return null;
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace Kirby\Panel\Lab;
use Kirby\Template\Snippet as BaseSnippet;
/**
* Custom snippet class for lab examples
*
* @internal
* @since 4.0.0
* @codeCoverageIgnore
*
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Snippet extends BaseSnippet
{
public static function root(): string
{
return __DIR__ . '/snippets';
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace Kirby\Panel\Lab;
use Kirby\Template\Template as BaseTemplate;
/**
* Custom template class for lab examples
*
* @internal
* @since 4.0.0
* @codeCoverageIgnore
*
* @package Kirby Panel
* @author Bastian Allgeier <bastian@getkirby.com>
* @link https://getkirby.com
* @copyright Bastian Allgeier
* @license https://getkirby.com/license
*/
class Template extends BaseTemplate
{
public function __construct(
public string $file
) {
parent::__construct(
name: basename($this->file)
);
}
public function file(): string|null
{
return $this->file;
}
}