🎉 initial commit

This commit is contained in:
2024-10-07 22:58:27 +02:00
commit d3eccf99f0
824 changed files with 16401 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
<?php
namespace Application;
use Application\Helper\ArrayPath;
use Dotenv\Dotenv;
use Exception;
class AppEnvironment
{
private static ?AppEnvironment $instance = null;
private array $environment = [];
/**
* The Singleton's constructor should always be private to prevent direct
* construction calls with the `new` operator.
*/
protected function __construct()
{
$this->loadEnvironment();
}
/**
* Singletons should not be cloneable.
*/
protected function __clone() {}
/**
* Singletons should not be restorable from strings.
*
* @throws Exception
*/
public function __wakeup()
{
throw new Exception("Cannot unserialize a singleton.");
}
/**
* This is the static method that controls the access to the singleton
* instance. On the first run, it creates a singleton object and places it
* into the static field. On subsequent runs, it returns the client existing
* object stored in the static field.
*
* This implementation lets you subclass the Singleton class while keeping
* just one instance of each subclass around.
*/
public static function getInstance(): AppEnvironment
{
if (!isset(self::$instance)) {
self::$instance = new static();
}
return self::$instance;
}
/**
* Finally, any singleton should define some business logic, which can be
* executed on its instance.
*/
private function loadEnvironment(): void
{
if (!empty($this->environment)) {
return;
}
$this->environment = Dotenv::createImmutable($_SERVER['DOCUMENT_ROOT'] . '/../')->load();
}
/**
* @throws Exception
*/
public function getVariable(string|array $key)
{
return ArrayPath::GetDataFromArray($this->environment, $key);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Application\Breadcrumbs;
class Breadcrumb {
/**
* @param string $name
* @param string $url
* @param bool $active
* @param bool $current
*/
public function __construct(
private string $name,
private string $url,
private bool $active = true,
private bool $current = true
) {}
public function setAsNotCurrent() {
$this->current = false;
}
public function getTemplateData() {
return array(
'name' => $this->name,
'url' => $this->url,
'active' => $this->active,
'current' => $this->current,
);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Application\Breadcrumbs;
class Breadcrumbs {
/**
* @var Breadcrumb[]
*/
private array $breadcrumbs;
public function __construct() {
$this->breadcrumbs[] = new Breadcrumb('Start', '/');
}
public function addBreadcrumb(string $name, string $url, bool $active = true, $current = true) {
if ($current) {
$this->setAllNonCurrent();
}
if (empty($url)) {
$active = false;
}
$this->breadcrumbs[] = new Breadcrumb($name, $url, $active, $current);
}
private function setAllNonCurrent(): void {
foreach ($this->breadcrumbs as $breadcrumb) {
$breadcrumb->setAsNotCurrent();
}
}
public function getTemplateData() {
$return = array();
foreach ($this->breadcrumbs as $breadcrumb) {
$return[] = $breadcrumb->getTemplateData();
}
return $return;
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace Application\Content;
use Application\AppEnvironment;
use Application\Models\Article;
use Application\Models\Site;
use Exception;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use JMS\Serializer\SerializerBuilder;
class CockpitApi
{
private ?Client $client = null;
public function __construct()
{
$this->client = new Client();
}
/**
* @param string $type
* @param array $variables
*
* @return string
* @throws Exception
*/
private function getApiPath(string $type = 'item', array $variables = []): string
{
$path = match ($type) {
'itemByModel' => '/content/item/{model}',
'itemsByModel' => '/content/items/{model}',
'itemByModelAndId' => '/content/item/{model}/{id}',
'items' => '/content/items',
'aggregateByModel' => '/content/aggregate/{model}',
'treeByModel' => '/content/tree/{model}',
'healthcheck' => '/system/healthcheck',
default => throw new Exception("Type '$type' does not exist."),
};
foreach ($variables as $search => $replace) {
$path = str_replace('{' . $search . '}', $replace, $path);
}
if (str_contains($path, '{')) {
throw new Exception("Non replaced variable in path '$path'.");
}
return AppEnvironment::getInstance()->getVariable('COCKPIT_API_PATH') . $path;
}
/**
* @throws GuzzleException
* @throws Exception
*/
public function getGlobalData(): Site
{
$res = $this->client->request('GET', $this->getApiPath('itemByModel', ['model' => 'site']), [
'headers' => [
'api-key' => AppEnvironment::getInstance()->getVariable('COCKPIT_API_KEY'),
],
]);
return $this->deserialize($res->getBody()->getContents(), Site::class);
}
/**
* @throws GuzzleException
* @throws Exception
*/
public function getArticles(): Article|array
{
$res = $this->client->request('GET', $this->getApiPath('itemsByModel', ['model' => 'article']), [
'headers' => [
'api-key' => AppEnvironment::getInstance()->getVariable('COCKPIT_API_KEY'),
],
'query' => [
'filter' => null, # '{fieldA:"test"}'
'fields' => null,
'sort' => null,
'limit' => 10,
'skip' => 0,
'populate' => 1,
],
]);
return $this->deserialize($res->getBody()->getContents(), Article::class);
}
/**
* @throws GuzzleException
* @throws Exception
*/
public function getArticle(string $id): Article
{
$res = $this->client->request('GET', $this->getApiPath('itemByModelAndId', ['model' => 'article', 'id' => $id]), [
'headers' => [
'api-key' => AppEnvironment::getInstance()->getVariable('COCKPIT_API_KEY'),
],
'query' => [
'filter' => null, # "{_id:$id}",
'fields' => null,
'sort' => null,
'limit' => 1,
'skip' => 0,
'populate' => 1,
],
]);
return $this->deserialize($res->getBody()->getContents(), Article::class);
}
/**
* Deserialize JSON data into objects.
*
* @param string $json
* @param string $type
*
* @return mixed
*/
private function deserialize(string $json, string $type): mixed
{
$array = json_decode($json, true);
if (!isset($array['data'])) {
return SerializerBuilder::create()->build()->deserialize($json, $type, 'json');
}
$return = [];
foreach ($array['data'] as $data) {
$json = json_encode($data);
dump($json);
$return[] = SerializerBuilder::create()->build()->deserialize($json, $type, 'json');
}
return $return;
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Application\Controller;
use Application\Content\CockpitApi;
use Exception;
use GuzzleHttp\Exception\GuzzleException;
class ArticleController extends MainController
{
/**
* @throws Exception
* @throws GuzzleException
*/
protected function action(): void
{
$article = (new CockpitApi())->getArticle($this->getRouteParams('id'));
$this->pushToStash('article', [
'subtitle' => $this->faker->sentence(2),
'title' => $article->getTitle(),
'lead' => $this->faker->paragraph(),
'text' => $article->getText(),
'image' => [
'src' => 'https://picsum.photos/seed/' . $this->faker->randomDigit() . '/1920/1080',
'width' => 1920,
'height' => 1080,
'alt' => 'lorem ipsum',
],
]);
}
protected function getTemplate(): string
{
return '04_templates/t-article/t-article.twig';
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace Application\Controller;
use Application\Generator\Article;
use Application\Helper\NumberHash;
use Exception;
class HomeController extends MainController
{
/**
* @throws Exception
*/
protected function action(): void
{
$this->pushToStash('articles', $this->generateArticles());
}
private function generateArticles(): array
{
$return = [];
for ($i = 0; $i < 10; $i++) {
$return[] = (new Article(NumberHash::numHash("home_$i")))->getTeaserData();
}
return $return;
}
protected function getTemplate(): string
{
return '04_templates/t-home/t-home.twig';
}
}

View File

@@ -0,0 +1,210 @@
<?php
namespace Application\Controller;
use Application\Breadcrumbs\Breadcrumbs;
use Application\Helper\ArrayPath;
use Exception;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
use Twig\Loader\FilesystemLoader;
use Faker\Factory;
use Faker\Generator;
abstract class MainController
{
/**
* Path to twig template folder.
*/
public const DIR_TEMPLATES = __DIR__ . '/../../../templates';
/**
* Path to cache folder.
*/
public const DIR_CACHE = __DIR__ . '/../../../cache';
/**
* Array key for global stash scope.
*/
private const STASH_GLOBAL = 'global';
/**
* Array key for local stash scope.
*/
private const STASH_LOCAL = 'local';
/**
* Stash. This array will be sent to twig for rendering.
*
* @var array
*/
private array $stash = array();
/**
* URL parameters obtained by routing.
*
* @var array
*/
private array $route_parameters = array();
/**
* Fake data generator.
*
* @var Generator
*/
protected Generator $faker;
/**
* Breadcrumbs.
*
* @var Breadcrumbs
*/
private Breadcrumbs $breadcrumbs;
/**
* @throws RuntimeError
* @throws SyntaxError
* @throws LoaderError
* @throws Exception
*/
public function __invoke(array $parameters = array()): void
{
$this->route_parameters = $parameters;
$this->faker = Factory::create();
$this->faker->seed(1234567);
$this->preAction();
$this->action();
$this->postAction();
$this->render();
}
/**
* @throws Exception
*/
protected function getRouteParams(string|array $params)
{
return ArrayPath::GetDataFromArray($this->route_parameters, $params);
}
protected abstract function action(): void;
protected abstract function getTemplate(): string;
/**
* @throws Exception
*/
private function preAction(): void
{
$this->headerData();
$this->menuData();
$this->breadcrumbs = new Breadcrumbs();
$this->pushToStash('foo', 'bar', self::STASH_GLOBAL);
}
/**
* @return void
* @throws Exception
*/
private function postAction(): void
{
$this->BreadcrumbsToStash();
}
/**
* @return void
* @throws Exception
*/
private function menuData(): void
{
$this->pushToStash(
'menu',
[
[
'name' => 'Home',
'url' => '/',
],
[
'name' => 'News',
'url' => '/news',
],
[
'name' => 'Artikel',
'url' => '/article',
],
],
self::STASH_GLOBAL
);
}
/**
* Create header and meta-data.
*
* @return void
* @throws Exception
*/
private function headerData(): void
{
$this->pushToStash(
'meta',
[
'title' => $this->faker->domainName(),
],
self::STASH_GLOBAL
);
}
/**
* @throws Exception
*/
protected function pushToStash(string $name, mixed $data, string $context = self::STASH_LOCAL): void
{
if (array_key_exists($context, $this->stash) and array_key_exists($name, $this->stash[$context])) {
throw new Exception("The stash variable '$name' is already set!");
}
$this->stash[$context][$name] = $data;
}
protected function getBreadcrumbs()
{
return $this->breadcrumbs;
}
/**
* @throws SyntaxError
* @throws RuntimeError
* @throws LoaderError
*/
private function render(): void
{
$twig = new Environment(
new FilesystemLoader(self::DIR_TEMPLATES),
array(
'cache' => self::DIR_CACHE . '/twig',
'auto_reload' => true,
)
);
echo $twig->render(
$this->getTemplate(),
$this->getStash()
);
}
private function getStash(): array
{
return $this->stash;
}
/**
* @throws Exception
*/
private function BreadcrumbsToStash(): void
{
$this->pushToStash('breadcrumbs', $this->getBreadcrumbs()->getTemplateData(), self::STASH_GLOBAL);
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Application\Generator;
class Article extends WebsiteObjectGenerator
{
public function __construct(protected readonly string $seed)
{
parent::__construct($seed);
}
private function getHeadline(): string
{
return $this->getGenerator()->sentence(8);
}
private function getKicker(): string
{
return $this->getGenerator()->word();
}
private function getTeaserImage(): array
{
return (new Image($this->getSeed()))->getImage(640, 480);
}
private function getHeroImage(): array
{
return (new Image($this->getSeed()))->getImage(1920, 1080);
}
private function getExcerpt(): string
{
return implode(' ', $this->getGenerator()->sentences(5));
}
private function getUrl(): string
{
return '/article/test-' . $this->getSeed();
}
public function getTeaserData(): array
{
return [
'headline' => $this->getHeadline(),
'kicker' => $this->getKicker(),
'excerpt' => $this->getExcerpt(),
'teaserImage' => $this->getTeaserImage(),
'url' => $this->getUrl(),
];
}
public function getFullData(): array
{
return [
'headline' => $this->getHeadline(),
'kicker' => $this->getKicker(),
'excerpt' => $this->getExcerpt(),
'heroImage' => $this->getHeroImage(),
'url' => $this->getUrl(),
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace Application\Generator;
use Application\Generator\WebsiteObjectGenerator;
class Image extends WebsiteObjectGenerator
{
public function __construct(protected readonly string $seed)
{
parent::__construct($seed);
}
public function getImage(int $width = 1280, int $height = 720): array
{
return [
'src' => 'https://picsum.photos/seed/' . $this->seed . "/$width/$height",
'width' => $width,
'height' => $height,
'alt' => $this->getGenerator()->sentence(),
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Application\Generator;
use Faker\Factory;
use Faker\Generator;
class WebsiteObjectGenerator
{
/**
* @var Generator
*/
private Generator $generator;
public function __construct(private readonly string $seed)
{
$this->generator = Factory::create();
$this->getGenerator()->seed($this->seed);
}
protected function getGenerator(): Generator
{
return $this->generator;
}
protected function getSeed(): string
{
return $this->seed;
}
}

View File

@@ -0,0 +1,82 @@
<?php
namespace Application\Helper;
use Exception;
/**
* Return data from a nested array by providing a path. The parts of the path are seperated by commas.
*
* Examples: 'this.is.a.path' | 'root-node'
*/
class ArrayPath {
/**
* Return array data by given paths.
*
* Accepts no key (return all data) or a string (one specific data) or array (returns multiple data).
*
* @throws Exception|Exception
*/
public static function GetDataFromArray(array $array, mixed $key = null): mixed {
if (!$key) {
return $array;
}
if (is_string($key) and strpos($key, '.')) {
return self::getArrayDataByPath($array, $key);
}
if (is_string($key) and array_key_exists($key, $array)) {
return $array[$key];
}
if (is_array($key)) {
return self::GetMultipleData($array, $key);
}
return null;
}
/**
* Get data from a nested array by providing a path.
*
* @param string|array $data array to search the data on (haystack)
* @param string|array $path path(s) with dot-seperated parts (needle(s))
*
* @return mixed
*/
private static function getArrayDataByPath(mixed $data, string|array $path): mixed {
if (is_string($path)) {
$path = explode('.', $path);
}
if (count($path) === 0) {
return $data;
}
$key = array_shift($path);
return isset($data[$key]) ? self::getArrayDataByPath($data[$key], $path) : '';
}
/**
* Return multiple data by providing an array of desired keys and not just one as a string.
*
* @param array $data Haystack
* @param array $key Needles
*
* @return array
* @throws Exception
*/
private static function GetMultipleData(array $data, array $key): array {
$return = array();
foreach ($key as $key_value) {
$return[$key_value] = self::GetDataFromArray($data, $key_value);
}
return $return;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace Application\Helper;
class NumberHash
{
/**
* Return a number only hash
*
* @see https://stackoverflow.com/a/23679870/175071
*
* @param string $string string to hash
* @param null|int $length optional specific length
*
* @return int
*/
public static function numHash(string $string, ?int $length = null): int
{
$stringHash = md5($string, true);
$numberHash = unpack('N2', $stringHash);
$hash = $numberHash[1] . $numberHash[2];
if ($length) {
$hash = substr($hash, 0, $length);
}
return (int)$hash;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Application\Models;
use JMS\Serializer\Annotation\SerializedName;
use JMS\Serializer\Annotation\Type;
class Article
{
use Meta;
#[SerializedName('authors')]
#[Type('array<' . Author::class . '>')]
protected array $authors;
#[SerializedName('headline')]
#[Type('string')]
protected string $headline;
#[SerializedName('excerpt')]
#[Type('string')]
protected string $excerpt;
#[SerializedName('image')]
#[Type(Image::class)]
protected Image $image;
#[SerializedName('text')]
#[Type('string')]
protected string $text;
/**
* Return the title / headline.
*
* @return string
*/
public function getTitle(): string {
return $this->headline;
}
/**
* Return the text / article body text.
*
* @return string
*/
public function getText(): string {
return $this->text;
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Application\Models;
use JMS\Serializer\Annotation\SerializedName;
use JMS\Serializer\Annotation\Type;
class Author
{
use Meta;
#[SerializedName('name')]
#[Type('string')]
private string $name;
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Application\Models;
use JMS\Serializer\Annotation\SerializedName;
use JMS\Serializer\Annotation\Type;
class Image
{
use Meta;
#[SerializedName('path')]
#[Type('string')]
private string $path;
#[SerializedName('title')]
#[Type('string')]
private string $title;
#[SerializedName('mime')]
#[Type('string')]
private string $mime;
#[SerializedName('type')]
#[Type('string')]
private string $type;
#[SerializedName('description')]
#[Type('string')]
private string $description;
#[SerializedName('tags')]
#[Type('array')]
private array $tags;
#[SerializedName('size')]
#[Type('string')]
private string $size;
#[SerializedName('colors')]
#[Type('array')]
private array $colors;
#[SerializedName('width')]
#[Type('string')]
private string $width;
#[SerializedName('height')]
#[Type('string')]
private string $height;
#[SerializedName('altText')]
#[Type('string')]
private string $altText;
#[SerializedName('thumbhash')]
#[Type('string')]
private string $thumbhash;
#[SerializedName('folder')]
#[Type('string')]
private string $folder;
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Application\Models;
use DateTime;
use JMS\Serializer\Annotation\SerializedName;
use JMS\Serializer\Annotation\Type;
trait Meta
{
#[SerializedName('_model')]
#[Type('string')]
private ?string $metaModel;
#[SerializedName('_state')]
#[Type('int')]
private ?int $metaState;
#[SerializedName('_modified')]
#[Type('DateTime<"U">')]
private ?DateTime $metaModified;
#[SerializedName('_mby')]
#[Type('string')]
private ?string $metaMby;
#[SerializedName('_created')]
#[Type('DateTime<"U">')]
private ?DateTime $metaCreated;
#[SerializedName('_cby')]
#[Type('string')]
private ?string $metaCby;
#[SerializedName('_id')]
#[Type('string')]
private ?string $metaId;
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Application\Models;
use JMS\Serializer\Annotation\SerializedName;
use JMS\Serializer\Annotation\Type;
class Site
{
use Meta;
#[SerializedName('title')]
#[Type('string')]
private ?string $title;
public function getSiteTitle(): string
{
return $this->title;
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace Application;
use Application\Controller\HomeController;
use Application\Controller\ArticleController;
use Bramus\Router\Router as BramusRouter;
/**
* Simple router setup based on bramus/router.
*
* @link https://github.com/bramus/router
*/
class Router
{
private BramusRouter $router;
/**
* Constructor, setup router and routes
*/
public function __construct()
{
$this->setupRouter();
$this->setupRoutes();
}
/**
* Set up the router.
*
* @return void
*/
private function setupRouter(): void
{
$this->router = new BramusRouter();
$this->router->setNamespace('\Application\Controller');
}
/**
* Route-configuration will be done here.
*
* @return void
*/
private function setupRoutes(): void
{
$this->router->match('GET', '/', function () {
(new HomeController())();
});
$this->router->match('GET', '/messages', function () {
echo "<div>Lorem Ipsum Dolor</div>";
});
$this->router->match('GET', '/style.css', function () {
(new ScssCompiler())->compile();
});
$this->router->match('GET', '/debugbar/styles.css', function () {
#DebugBar::getInstance()->dumpCssAssets();
});
$this->router->match('GET', '/debugbar/javascript.js', function () {
#DebugBar::getInstance()->dumpJsAssets();
});
$this->router->match('GET', '/article/(\w+)-(\w+)', function ($name, $id) {
(new ArticleController())(['name' => $name, 'id' => $id]);
});
}
/**
* Run the routing and thus call the controllers.
*
* @return void
*/
public function serve(): void
{
$this->router->run();
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Application;
use Application\Controller\MainController;
use ScssPhp\ScssPhp\Compiler;
use ScssPhp\ScssPhp\Exception\SassException;
use ScssPhp\ScssPhp\OutputStyle;
use ScssPhp\Server\Server;
use ScssPhp\Server\ServerException;
class ScssCompiler
{
/**
* @throws ServerException
* @throws SassException
*/
public function compile(): void
{
$scss = new Compiler();
$scss->setImportPaths(MainController::DIR_TEMPLATES);
$scss->setOutputStyle(OutputStyle::EXPANDED);
$scss->setSourceMap(Compiler::SOURCE_MAP_INLINE);
$_GET['p'] = 'main.scss';
$server = new Server(MainController::DIR_TEMPLATES, MainController::DIR_CACHE . '/scss', $scss);
$server->serve();
}
}