initial commit

master
Michael Ochmann 10 months ago
commit c8c58fc72b
  1. 5
      .gitignore
  2. 21
      LICENSE.md
  3. 2
      README.md
  4. 26
      composer.json
  5. 1594
      composer.lock
  6. 103
      src/Application.php
  7. 10
      src/RequestType.php
  8. 31
      src/TemplateEngine.php
  9. 16
      src/Utils.php
  10. 57
      src/containers/Container.php
  11. 12
      src/containers/ControllerContainer.php
  12. 13
      src/containers/ServiceContainer.php
  13. 8
      src/controllers/Controller.php
  14. 12
      src/exceptions/UnknownValidatorException.php
  15. 66
      src/models/DatabaseModel.php
  16. 5
      src/models/Model.php
  17. 25
      src/services/Database.php
  18. 57
      src/services/Request.php
  19. 108
      src/services/Router.php
  20. 6
      src/services/Service.php
  21. 11
      src/validators/Validation.php
  22. 9
      src/validators/ValidationType.php
  23. 87
      src/validators/Validator.php
  24. 12
      views/404.mustache
  25. 35
      views/LucidityException.mustache
  26. 72
      views/partials/LucidityLayout.mustache

5
.gitignore vendored

@ -0,0 +1,5 @@
.idea
.vscode
.DS_Store
vendor

@ -0,0 +1,21 @@
# The MIT License
Copyright (C) 2024, MikO <miko@massivedynamic.eu>
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.

@ -0,0 +1,2 @@
# lucidity
– a MVC framework for php applications

@ -0,0 +1,26 @@
{
"name": "massivedynamic/lucidity",
"description": "a lightweight php framework",
"type": "library",
"version" : "0.0.1",
"license": "MIT",
"autoload": {
"psr-4": {
"massivedynamic\\lucidity\\": "src/"
}
},
"authors": [
{
"name": "MikO",
"email": "miko@massivedymamic.eu"
}
],
"require": {
"robmorgan/phinx": "^0.16.2",
"mustache/mustache": "^2.14",
"psr/container": "^2.0"
},
"require-dev": {
"phpstan/phpstan": "^1.11"
}
}

1594
composer.lock generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,103 @@
<?php declare(strict_types=1);
namespace massivedynamic\lucidity {
use Exception;
use Throwable;
use massivedynamic\lucidity\TemplateEngine;
use massivedynamic\lucidity\Utils;
use massivedynamic\lucidity\containers\ServiceContainer;
use massivedynamic\lucidity\services\Router;
use massivedynamic\lucidity\services\Service;
abstract class Application {
protected static ?Application $App = null;
/** @var array<string, mixed> */
protected array $globals;
protected TemplateEngine $templateEngine;
protected ServiceContainer $serviceContainer;
protected Router|Service $router;
public function __construct(private string $templatesPath, string $partialsPath, string $cachePath) {
set_exception_handler(fn(Throwable $exception) => $this->handleException($exception));
if (self::$App instanceof Application)
throw new Exception("Only one instance of `Application` is possible");
session_start();
self::$App = $this;
$this->globals = [
"errors" => []
];
$this->templateEngine = new TemplateEngine($this, $templatesPath, $partialsPath, $cachePath);
$this->serviceContainer = new ServiceContainer($this);
$this->router = $this->serviceContainer->get(Router::class);
}
public function fetchService(string $id) : Service | Application {
return $this->serviceContainer->get($id);
}
public function publishGlobal(string $key, mixed $value) : void {
if ($key === "errors")
throw new Exception("The 'errors' global can not be accessed directly. Use `Application::addError()` instead.");
$this->globals[$key] = $value;
}
/**
* @param array<string> | string | null $errors
*/
public function addError(array | string | null $errors) : void {
if (!$errors)
return;
if (!is_array($errors))
$errors = [$errors];
$this->globals["errors"] = array_merge((array)$this->globals["errors"], $errors);
}
/**
* @return array<string, mixed>
*/
public function globals() : array {
return $this->globals;
}
private function handleException(Throwable $exception) : void {
//Utils::Dump($exception->getTrace());
self::View("LucidityException", [
"exception" => $exception,
"trace" => $exception->getTrace(),
"exceptionType" => $exception::class,
"lucidityVersion" => \Composer\InstalledVersions::getVersion("massivedynamic/lucidity"),
"phpVersion" => phpversion()
]);
}
/**
* @param string $template
* @param array<string, mixed> $context
*/
public static function View(string $template, array $context = []) : void {
self::$App->templateEngine->view($template, $context);
}
public static function Instance() : Application {
return self::$App;
}
}
}
namespace {
/**
* @param string $template
* @param array<string, mixed> $context
*/
function view(string $template, array $context = []) : void {
massivedynamic\lucidity\Application::View($template, $context);
}
}

@ -0,0 +1,10 @@
<?php declare(strict_types=1);
namespace massivedynamic\lucidity;
enum RequestType : string {
case GET = "GET";
case POST = "POST";
case PUT = "PUT";
case DELETE = "DELETE";
}

@ -0,0 +1,31 @@
<?php declare(strict_types=1);
namespace massivedynamic\lucidity;
use Mustache_Engine;
use Mustache_Loader_FilesystemLoader;
use Mustache_Loader_CascadingLoader;
class TemplateEngine extends Mustache_Engine {
public function __construct(private Application $app, private string $templatesPath, string $partialsPath, string $cachePath) {
parent::__construct([
"cache" => $cachePath,
"loader" => new Mustache_Loader_CascadingLoader([
new Mustache_Loader_FilesystemLoader($templatesPath),
new Mustache_Loader_FilesystemLoader(dirname(__FILE__)."/../views")
]),
"partials_loader" => new Mustache_Loader_CascadingLoader([
new Mustache_Loader_FilesystemLoader($partialsPath),
new Mustache_Loader_FilesystemLoader(dirname(__FILE__)."/../views/partials")
]),
"helpers" => [
"notEmpty" => fn(array $array) : bool => count($array) > 0
],
"pragmas" => [Mustache_Engine::PRAGMA_BLOCKS, Mustache_Engine::PRAGMA_FILTERS]
]);
}
public function view(string $template, array $context = []) : void {
echo $this->render("$template.mustache", array_merge($this->app->globals(), $context));
}
}

@ -0,0 +1,16 @@
<?php declare(strict_types=1);
namespace massivedynamic\lucidity;
class Utils {
public static function Dump(mixed $content) : void {
echo "<pre>";
print_r($content);
echo "</pre>";
}
public static function DD(mixed $content) : void {
self::Dump($content);
die();
}
}

@ -0,0 +1,57 @@
<?php declare(strict_types=1);
namespace massivedynamic\lucidity\containers;
use Exception;
use ReflectionClass;
use massivedynamic\lucidity\Application;
use Psr\Container\ContainerInterface;
abstract class Container implements ContainerInterface {
protected array $instances;
public function __construct(protected Application $app) {
$this->instances = [];
}
public function get(string $id) : mixed {
if ($id === Application::class || is_subclass_of($id, Application::class))
return $this->app;
if (!$this->has($id))
$this->instantiate($id);
return $this->instances[$id];
}
public function has(string $id) : bool {
return array_key_exists($id, $this->instances);
}
public function emplace(string $type, ...$args) : bool {
if (array_key_exists($type, $this->instances))
return false;
$this->instances[$type] = new $type(...$args);
return true;
}
protected function instantiate(string $id) : void {
$classInfo = new ReflectionClass($id);
$constructor = $classInfo->getConstructor();
$arguments = [];
if ($constructor === null) {
$this->instances[$id] = $classInfo->newInstance();
return;
}
foreach ($constructor->getParameters() as $parameter) {
$type = $parameter->getType();
if ($type === null)
throw new Exception("constructor arguments need to name a type");
array_push($arguments, $this->get($type->getName()));
}
$this->instances[$id] = $classInfo->newInstanceArgs($arguments);
}
}

@ -0,0 +1,12 @@
<?php declare(strict_types=1);
namespace massivedynamic\lucidity\containers;
use massivedynamic\lucidity\containers\Container;
use massivedynamic\lucidity\controllers\Controller;
class ControllerContainer extends Container {
public function get(string $id) : Controller {
return parent::get($id);
}
}

@ -0,0 +1,13 @@
<?php declare(strict_types=1);
namespace massivedynamic\lucidity\containers;
use massivedynamic\lucidity\Application;
use massivedynamic\lucidity\containers\Container;
use massivedynamic\lucidity\services\Service;
class ServiceContainer extends Container {
public function get(string $id) : Service | Application {
return parent::get($id);
}
}

@ -0,0 +1,8 @@
<?php declare(strict_types=1);
namespace massivedynamic\lucidity\controllers;
class Controller {
public function __construct() {
}
}

@ -0,0 +1,12 @@
<?php declare(strict_types=1);
namespace massivedynamic\lucidity\exceptions;
use Exception;
use Throwable;
class UnknownValidatorException extends Exception {
public function __construct(string $validator, ?Throwable $previous = null) {
parent::__construct("Unknown validator '$validator'", 1000, $previous);
}
}

@ -0,0 +1,66 @@
<?php declare(strict_types=1);
namespace massivedynamic\lucidity\models;
use ReflectionClass;
use ReflectionProperty;
use ReflectionUnionType;
use massivedynamic\lucidity\Application;
use massivedynamic\lucidity\Utils;
use massivedynamic\lucidity\models\Model;
use massivedynamic\lucidity\services\Database;
use massivedynamic\lucidity\services\Service;
abstract class DatabaseModel extends Model {
protected static ?string $TableName = null;
protected Service | Database $db;
public function __construct() {
$this->db = Application::Instance()->fetchService(Database::class);
}
public function save() {
$classInfo = new ReflectionClass($this);
$properties = $classInfo->getProperties(ReflectionProperty::IS_PROTECTED);
$columns = [];
$values = [];
$types = [];
foreach ($properties as $property) {
if ($property->getType() instanceof ReflectionUnionType || $property->isStatic() || !$property->getType()->isBuiltin())
continue;
array_push($columns, $property->name);
array_push($values, $this->{$property->name});
array_push($types, Database::PrimitiveTypeToBindParam($property->getType()->getName()));
}
$placeholders = array_fill(0, count($columns), '?');
$qs = "INSERT INTO ".self::TableName()." (".implode(", ", $columns).") VALUES (".implode(", ", $placeholders).")";
$query = $this->db->prepare($qs);
$query->bind_param(implode("", $types), ...$values);
}
public function all() : array {
$result = $this->db->query("SELECT * FROM ".self::TableName());
$out = [];
while ($row = $result->fetch_object())
array_push($out, $row);
return $out;
}
private static function TableName() : string {
return self::$TableName ?? self::Type2TableName(static::class);
}
private static function Type2TableName(string $type) : string {
$parts = explode("\\", $type);
$basename = strtolower(array_pop($parts));
return substr($basename, -1) === "s" ? $basename : $basename."s";
}
}

@ -0,0 +1,5 @@
<?php declare(strict_types=1);
namespace massivedynamic\lucidity\models;
abstract class Model {}

@ -0,0 +1,25 @@
<?php declare(strict_types=1);
namespace massivedynamic\lucidity\services;
use mysqli;
use massivedynamic\lucidity\Utils;
use massivedynamic\lucidity\services\Service;
class Database extends mysqli implements Service {
public static function PrimitiveTypeToBindParam(string $type) : string {
switch($type) {
case "bool":
return "i";
case "string":
return "s";
case "int":
return "i";
case "float":
return "d";
default:
return "X";
}
}
}

@ -0,0 +1,57 @@
<?php declare(strict_types=1);
namespace massivedynamic\lucidity\services;
use massivedynamic\lucidity\RequestType;
use massivedynamic\lucidity\Utils;
use massivedynamic\lucidity\services\Service;
use massivedynamic\lucidity\validators\Validator;
class Request implements Service {
public readonly RequestType $type;
public readonly string $query;
/** @var array<string, string> */
private readonly array $get;
/** @var array<string, string> */
private readonly array $post;
public function __construct() {
$queryString = parse_url("/".ltrim($_SERVER["REQUEST_URI"], '/'), PHP_URL_PATH);
assert($queryString !== false, "unreachable");
$this->type = RequestType::tryFrom($_SERVER["REQUEST_METHOD"]) ?? RequestType::GET;
$this->query = !$queryString ? "" : $queryString;
$this->get = $_GET;
$this->post = $_POST;
}
/**
* @param array<string, array<string>> $attributes
*/
public function validate(array $attributes) : bool {
$validator = new Validator($this->all(), $attributes);
return $validator->validate() === null;
}
public function match(string $pattern) : bool {
$pattern = "/^".preg_replace("/\//", "\/", $pattern)."$/";
return preg_match($pattern, $this->query) === 1;
}
/**
* @return array<string, string>
*/
public function all() : array {
return $this->type === RequestType::POST ? $this->post : $this->get;
}
public function get(string $key, ?string $defaultValue = null) : ?string {
return isset($this->get[$key]) ? $this->get[$key] : $defaultValue;
}
public function post(string $key, ?string $defaultValue = null) : ?string {
return isset($this->post[$key]) ? $this->post[$key] : $defaultValue;
}
}

@ -0,0 +1,108 @@
<?php declare(strict_types=1);
namespace massivedynamic\lucidity\services {
use Exception;
use ReflectionFunction;
use ReflectionMethod;
use massivedynamic\lucidity\Application;
use massivedynamic\lucidity\Utils;
use massivedynamic\lucidity\RequestType;
use massivedynamic\lucidity\containers\ControllerContainer;
use massivedynamic\lucidity\services\Request;
use massivedynamic\lucidity\services\Service;
class Router implements Service {
private ControllerContainer $controllerContainer;
private array $routes;
public function __construct(private Application $app, private Request $request) {
$this->controllerContainer = new ControllerContainer($app);
$this->routes = [
RequestType::GET->value => [],
RequestType::POST->value => [],
RequestType::PUT->value => [],
RequestType::DELETE->value => [],
];
}
private function addCallback(RequestType $type, string $route, callable | array $callback) : void {
$this->routes[$type->value][$route] = $callback;
}
public function get(string $route, callable | array $callback) : void {
$this->addCallback(RequestType::GET, $route, $callback);
}
public function post(string $route, callable | array $callback) : void {
$this->addCallback(RequestType::POST, $route, $callback);
}
public function put(string $route, callable | array $callback) : void {
$this->addCallback(RequestType::PUT, $route, $callback);
}
public function delete(string $route, callable | array $callback) : void {
$this->addCallback(RequestType::DELETE, $route, $callback);
}
public function view(string $route, string $template, array $context = []) {
$this->addCallback(RequestType::GET, $route, fn() => view($template, $context));
}
public function route() : void {
$matched = false;
foreach ($this->routes[$this->request->type->value] as $route => $callback) {
if ($this->request->match($route)) {
$matched = true;
$this->call($callback);
}
}
if (!$matched)
view("404");
}
public static function Redirect(string $target) : void {
header("Location: $target");
}
private function call(callable | array $callback) : void {
$callbackInfo = null;
if (is_array($callback)) {
assert(count($callback) === 2, "only callables can be routed");
[$class, $method] = $callback;
$callbackInfo = new ReflectionMethod($class, $method);
$arguments = $this->getArgs($callbackInfo);
$callbackInfo->invokeArgs($this->controllerContainer->get($class), $arguments);
} else {
$callbackInfo = new ReflectionFunction($callback);
$arguments = $this->getArgs($callbackInfo);
$callbackInfo->invokeArgs($arguments);
}
}
private function getArgs(ReflectionFunction | ReflectionMethod $function) : array {
$arguments = [];
foreach ($function->getParameters() as $parameter) {
$type = $parameter->getType();
if ($type === null)
throw new Exception("constructor arguments need to name a type");
array_push($arguments, $this->app->fetchService($type->getName()));
}
return $arguments;
}
}
}
namespace {
function redirect(string $target) {
massivedynamic\lucidity\services\Router::Redirect($target);
}
}

@ -0,0 +1,6 @@
<?php declare(strict_types=1);
namespace massivedynamic\lucidity\services;
interface Service {
}

@ -0,0 +1,11 @@
<?php declare(strict_types=1);
namespace massivedynamic\lucidity\validators;
// @struct
class Validation {
public function __construct(
public readonly ValidationType $type,
public readonly string $parameter
){}
}

@ -0,0 +1,9 @@
<?php declare(strict_types=1);
namespace massivedynamic\lucidity\validators;
enum ValidationType : string {
case REQUIRED = "required";
case MIN = "min";
case MAX = "max";
}

@ -0,0 +1,87 @@
<?php declare(strict_types=1);
namespace massivedynamic\lucidity\validators;
use Exception;
use massivedynamic\lucidity\exceptions\UnknownValidatorException;
use massivedynamic\lucidity\validators\Validation;
use massivedynamic\lucidity\validators\ValidationType;
use massivedynamic\lucidity\Utils;
class Validator {
private array $errors;
private array $validators;
public function __construct(private array $pool, array $validation) {
$this->errors = [];
$this->validators = self::ParseValidationArray($validation);
}
public function validate() : ?array {
foreach ($this->validators as $key => $validators) {
foreach($validators as $validator) {
switch ($validator->type) {
case ValidationType::REQUIRED:
$this->validateRequired($key);
break;
case ValidationType::MIN:
$this->validateMin($key, $validator);
break;
case ValidationType::MAX:
$this->validateMax($key, $validator);
break;
}
}
}
return count($this->errors) > 0 ? $this->errors : null;
}
private function validateMin(string $key, Validation $validator) : void {
$length = strlen($this->pool[$key]);
if ($length < intval($validator->parameter))
array_push($this->errors, "Field '$key' must be at least $validator->parameter characters long (is $length)");
}
private function validateMax(string $key, Validation $validator) : void {
$length = strlen($this->pool[$key]);
if ($length > intval($validator->parameter))
array_push($this->errors, "Field '$key' must be at least $validator->parameter characters long (is $length)");
}
private function validateRequired(string $key) : void {
if (!isset($this->pool[$key]))
array_push($this->errors, "Field '$key' is required");
}
/**
* @@param array<string, array> $validation
*/
public static function ParseValidationArray(array $validation) : array {
$out = [];
foreach ($validation as $key => $validators) {
$validators = explode("|", $validators);
if (count($validators) < 1)
continue;
$out[$key] = [];
foreach ($validators as $validator) {
$parts = explode(":", $validator);
[$type, $argument] = count($parts) === 2 ? $parts : [$parts[0], ""];
$argument = $argument ?? "";
$typeResolved = ValidationType::tryFrom(strtolower($type));
if ($typeResolved === null)
throw new UnknownValidatorException($type);
$validator = new Validation($typeResolved, $argument);
array_push($out[$key], $validator);
}
}
return $out;
}
}

@ -0,0 +1,12 @@
{{<LucidityLayout}}
{{$content}}
<main class="card">
<h1>HTTP 404</h1>
<p>
page not found
</p>
</main>
{{/content}}
{{/LucidityLayout}}

@ -0,0 +1,35 @@
{{< LucidityLayout}}
{{$content}}
<article class="card">
<span class="badge">
{{exceptionType}}
</span>
<h2>
{{exception.getMessage}}
</h2>
{{#exception.getFile}}
<a href="zed://file/{{.}}:{{exception.getLine}}" class="file">{{.}}:{{exception.getLine}}</a>
{{/exception.getFile}}
<section class="info">
<span>PHP {{phpVersion}}</span>
<span>lucidity {{lucidityVersion}}</span>
</section>
</article>
<article class="card edgeless">
{{#trace}}
<article class="list-item">
{{#file}}
<span class="file">{{.}}:{{line}}</span>
{{/file}}
<h4>{{class}}<h4>
<h3>{{function}}</h3>
</article>
{{/trace}}
</article>
{{/content}}
{{/LucidityLayout}}

@ -0,0 +1,72 @@
<html>
<head>
<title>{{title}}</title>
<style type=text/css>
* {
box-sizing: border-box;
}
body {
font-family: sans-serif;
padding: 2rem;
background: #e5e7eb;
color: #333;
}
.badge {
background : #ddd;
display: inline-block;
padding: 0.5rem 1rem;
border-radius: 4px;
font-weight: bold;
color: #444;
}
.card {
background: white;
box-shadow: 0 0 16px rgba(0, 0, 0, 0.3);
margin-bottom: 4rem;
position: relative;
}
.card:not(.edgeless) {
padding: 2rem;
}
.list-item {
padding: 1rem 2rem;
transition: all 0.2s ease-in-out;
}
.list-item:not(:last-of-type) {
border-bottom : solid 1px #ddd;
}
.list-item:hover {
background: #dd524c;
}
.list-item:hover, .list-item:hover * {
color: white !important;
}
.file {
color: #aaa;
display: block;
}
.info {
display: flex;
white-space: nowrap;
gap: 1rem;
color: #888;
font-weight: bold;
position: absolute;
top: 2rem;
right: 2rem;
}
.info * {
flex: 1;
}
</style>
</head>
<body>
{{$content}}{{/content}}
Loading…
Cancel
Save