Initial commit

This commit is contained in:
Fristi 2024-05-19 15:45:12 +02:00
commit b1aa1608c3
Signed by: fristi
GPG key ID: F0197618CE6211DC
38 changed files with 2282 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
.idea/
/cache/
composer.lock
deploy.sh
deploy-excludes
/vendor/

10
.phpstorm.meta.php Normal file
View file

@ -0,0 +1,10 @@
<?php
namespace PHPSTORM_META
{
use Musoka\Zap\Application;
override(Application::get(), map([
'router' => Musoka\Zap\Routing\Router::class
]));
}

14
LICENSE Normal file
View file

@ -0,0 +1,14 @@
Copyright 2023-2024 CrocWork
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.

1
README.md Normal file
View file

@ -0,0 +1 @@

25
composer.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "crocwork/zappy-base",
"description": "PHP microframework for Musoka projects.",
"type": "library",
"license": "MIT",
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Musoka\\Zap\\": "src/"
}
},
"authors": [
{
"name": "Fristi",
"email": "support@musoka.network"
}
],
"minimum-stability": "stable",
"require": {
"php": ">=8.1",
"symfony/http-foundation": "^6.3"
}
}

228
src/Application.php Normal file
View file

@ -0,0 +1,228 @@
<?php
namespace Musoka\Zap;
use Musoka\Zap\Components\ComponentAggregator;
use Musoka\Zap\Components\ComponentProviderInterface;
use Musoka\Zap\Config\ConfigRepository;
use Musoka\Zap\ErrorHandling\Handler;
use Musoka\Zap\View\Renderable;
use Musoka\Zap\Http\Request;
use Musoka\Zap\Http\Response;
/**
* Application
*
* The Application class acts as the main entrypoint for Zap application.
* On instantiation, the class will set up required services and routing
* for the application.
*
* This class also acts as an access point for obtaining objects registered
* by services. Services will register magic methods on the Application
* instance (as well as docblock comments for hinting them) that can be used
* to obtain service objects.
*/
class Application
{
const VERSION = '0.0.1';
protected static self $instance;
protected ComponentAggregator $components;
protected string $basePath;
protected array $paths;
protected string $baseUrl;
protected Handler $errorHandler;
protected ConfigRepository $config;
protected Request $request;
public function __construct(string $basePath = null)
{
static::$instance = $this;
$this->components = new ComponentAggregator();
//Initialize error handling
$this->errorHandler = new Handler(true);
$this->errorHandler->register(fn(?\Throwable $e = null) => $this->sendErrorResponse($e));
//Setup base path
if(!$basePath || !file_exists($basePath) || !is_dir($basePath)) {
$basePath = implode('/', array_slice(explode('/', $_SERVER['SCRIPT_NAME']), 0, -2)) . '/';
}
$this->setBasePath($basePath); //TODO: move path management to own service?
//Setup base bindings and components
//Note that config and the request object are manually added, but can be overridden by other providers.
$this->config = new ConfigRepository(realpath($this->getConfigPath()), realpath($this->getBasePath('cache')));
$this->request = Request::createFromGlobals();
$this->components->addComponent('app', $this);
$this->components->addComponent('config', $this->config);
$this->components->addComponent('request', $this->request);
$this->baseUrl = $this->config->get('application.url') ?? $this->request->getBaseUrl();
//Discover services and add them to the container
//Includes services for translation, routing and the viewfactory.
$providers = $this->config->get('application.providers', []);
foreach($providers as $providerClass) {
try {
$provider = new $providerClass();
$this->components->addProvider($provider);
}
catch (\Throwable $e) {
print_r($e->getMessage());
continue;
}
/*if(class_exists($providerClass) && $providerClass instanceof ComponentProviderInterface) {
print_r($providerClass);
$provider = new $providerClass();
$this->components->addProvider($provider);
}*/
}
$this->components->registerComponents(); //TODO: maybe we should defer initializing components until needed.
}
public static function getInstance(): static
{
return static::$instance;
}
public function getComponents(): ComponentAggregator
{
return $this->components;
}
public function getBasePath(string $path = ''): string
{
return ($this->paths['base'] ?? $this->basePath) . ($path ? DIRECTORY_SEPARATOR.$path : '');
}
public function getConfigPath(string $path = ''): string
{
return ($this->paths['config'] ?? $this->getBasePath('config')) . ($path ? DIRECTORY_SEPARATOR.$path : '');
}
public function getLangPath(string $path = ''): string
{
return ($this->paths['lang'] ?? $this->getResourcePath('i18n')) . ($path ? DIRECTORY_SEPARATOR.$path : '');
}
public function getPublicPath(string $path = ''): string
{
return ($this->paths['public'] ?? $this->getBasePath('public_html')) . ($path ? DIRECTORY_SEPARATOR.$path : '');
}
public function getStoragePath(string $path = ''): string
{
return ($this->paths['storage'] ?? $this->getBasePath('storage')) . ($path ? DIRECTORY_SEPARATOR.$path : '');
}
public function getResourcePath(string $path = ''): string
{
return ($this->paths['resource'] ?? $this->getBasePath('resources')) . ($path ? DIRECTORY_SEPARATOR.$path : '');
}
public function getViewPath(string $path = ''): string
{
return ($this->paths['view'] ?? $this->getResourcePath('views')) . ($path ? DIRECTORY_SEPARATOR.$path : '');
}
public function getCachePath(string $path = ''): string
{
return ($this->paths['cache'] ?? $this->getBasePath('cache')) . ($path ? DIRECTORY_SEPARATOR.$path : '');
}
public function getBaseUrl(string $path = ''): string
{
return $this->baseUrl . (!empty($path) ? DIRECTORY_SEPARATOR.$path : '');
}
public function get(string $id = '')
{
return $this->components->get($id);
}
protected function setBasePath($basePath): static
{
$this->basePath = rtrim($basePath, '\/');
$this->paths = [
'base' => $this->getBasePath(),
'config' => $this->getConfigPath(),
'public' => $this->getPublicPath(),
'resources' => $this->getResourcePath(),
'storage' => $this->getStoragePath(),
'view' => $this->getViewPath(),
'lang' => $this->getLangPath(),
'cache' => $this->getCachePath(),
];
return $this;
}
public function run()
{
$router = $this->components->get('router');
if(!$router) {
throw new \RuntimeException('Could not get router instance, aborting.');
}
$response = $router->run();
$this->handleResponse($response);
}
/* TODO: move this to the router. */
protected function handleResponse($response)
{
//Response can be anything, ranging from text to views to already complete response objects.
if(!is_a($response, Response::class)) {
if($response === false) {
//Router did not find a route, so 404
$this->sendHTTPErrorResponse(404);
return;
}
if(empty($response)) {
$response = new Response('');
} elseif (is_a($response, Renderable::class)) {
$response = new Response($response->render());
} else {
$response = new Response((string) $response);
}
}
//If request was HEAD request, only send back headers.
if($this->request->getMethod() == 'HEAD') {
$response->sendHeaders();
} else {
$response->send();
}
}
protected function sendHTTPErrorResponse(int $status = 404, ?\Throwable $e, array $headers = [])
{
$response = new Response(
Response::$statusTexts[$status] ?? '' ,
$status,
$headers
);
$response->send();
}
protected function sendErrorResponse(?\Throwable $e)
{
$this->sendHTTPErrorResponse(500, $e);
}
}

View file

@ -0,0 +1,18 @@
<?php
namespace Musoka\Zap\Components;
abstract class AbstractComponentProvider implements ComponentProviderInterface
{
protected array $provides = [];
public function has(string $componentId): bool
{
return in_array($componentId, $this->provides);
}
public function provides(): array
{
return $this->provides;
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace Musoka\Zap\Components;
interface BootableProviderInterface extends ComponentProviderInterface
{
}

View file

@ -0,0 +1,98 @@
<?php
namespace Musoka\Zap\Components;
class ComponentAggregator
{
/**
* @var ComponentInterface[] List of registered components.
*/
protected array $components = [];
/**
* @var ComponentProviderInterface[] List of component providers.
*/
protected array $providers = [];
/**
* @var string[] List of initialized providers.
*/
protected array $registered = [];
public function addProvider(ComponentProviderInterface $provider): static
{
if(!key_exists($provider::class, $this->providers)) {
$this->providers[$provider::class] = $provider;
}
return $this;
}
public function addComponent(string $id, object|callable $component, bool $override = false): static
{
if($override || !key_exists($id, $this->components)) {
$this->components[$id] = $component;
}
return $this;
}
public function get(string $componentId)
{
try {
return $this->resolveComponent($componentId);
}
catch (\Exception $e) {
throw $e;
return null;
}
}
public function has(string $componentId): bool
{
return key_exists($componentId, $this->components);
}
public function provides(): array
{
return array_keys($this->components);
}
public function registerComponents()
{
foreach($this->providers as $provider) {
if(in_array($provider::class, $this->registered)) continue;
$provider->registerComponents($this);
$this->registered[] = $provider::class;
}
}
protected function resolveComponent(string $componentId)
{
if(!key_exists($componentId, $this->components)) {
foreach($this->providers as $provider) {
if($provider->has($componentId)) {
$provider->registerComponents($this);
//Check if provider didn't lie to us.
if(!key_exists($componentId, $this->components)) {
throw new \Exception('ComponentProvider did not register component.');
}
break;
}
}
if(!key_exists($componentId, $this->components)) {
throw new \Exception('Component unavailable; nothing registers this component.');
}
}
$component = $this->components[$componentId];
if(is_callable($component)) {
return call_user_func($component);
} else {
return $component;
}
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace Musoka\Zap\Components;
interface ComponentProviderInterface
{
public function has(string $componentId): bool;
public function provides(): array;
public function registerComponents(ComponentAggregator $aggregator): void;
}

View file

@ -0,0 +1,66 @@
<?php
namespace Musoka\Zap\Config;
class ConfigRepository
{
protected array $items;
public function __construct(string $configDirPath, string $cacheDirPath)
{
//Load from cache if it exists
if($this->loadFromCache($cacheDirPath)) {
return;
}
//Otherwise, load from config files
$this->loadConfigurationFiles($configDirPath);
}
public function get(string $key, $default = null)
{
$keys = explode('.', $key);
$value = $this->items;
foreach($keys as $index) {
if(!key_exists($index, $value)) return $default;
$value = $value[$index];
}
return $value;
}
protected function loadFromCache(string $cacheDirPath): bool
{
if(!file_exists($cacheDirPath) || !file_exists($cacheDirPath . DIRECTORY_SEPARATOR . 'config-cache.php')) {
return false;
}
$this->items = require $cacheDirPath . DIRECTORY_SEPARATOR . 'config-cache.php';
return true;
}
protected function loadConfigurationFiles(string $configDirPath)
{
$files = glob($configDirPath . DIRECTORY_SEPARATOR . '*.php');
if($files === false || empty($files)) {
throw new \RuntimeException('Unable to load configuration, no configuration files found.');
} elseif(!in_array($configDirPath . DIRECTORY_SEPARATOR . 'application.php', $files)) {
throw new \RuntimeException('Unable to load the application configuration, app configuration file is missing.');
}
$this->items = [];
foreach($files as $file) {
$items = require $file;
$key = basename($file, '.php');
$this->items[$key] = $items;
//$this->items = array_merge($this->items, $items);
}
}
protected function writeToCache()
{
// TODO: Write caching function for configuration
}
}

156
src/DIContainer.php Normal file
View file

@ -0,0 +1,156 @@
<?php
namespace Musoka\Zap;
class DIContainer
{
protected array $definitions = [];
protected array $services = [];
protected array $initializedServices = [];
public function get(string $id)
{
return $this->resolve($id);
}
public function add(string $id, callable $factory): DIContainer
{
if(isset($this->definitions[$id])) {
return $this;
}
$this->definitions[$id] = $factory;
return $this;
}
public function resolve(string $id)
{
if (key_exists($id, $this->definitions)) {
$factory = $this->definitions[$id];
return $factory($this);
}
if ($this->provides($id)) {
$this->initialize($id);
if (!key_exists($id, $this->definitions)) {
throw new \Exception(sprintf('Service does not properly provide (%s)', $id));
}
$factory = $this->definitions[$id];
return $factory($this);
}
throw new \Exception(sprintf('Definition (%s) has not been added to the container', $id));
}
public function has(string $id): bool
{
if (key_exists($id, $this->definitions) || $this->provides($id)) {
return true;
}
if ($this->provides($id)) {
return true;
}
return false;
}
private function make(string $class)
{
try {
$reflector = new \ReflectionClass($class);
} catch (\ReflectionException $e) {
throw new \Exception("Target class [$class] does not exist.", 0, $e);
}
// If the type is not instantiable, such as an Interface or Abstract Class
if (! $reflector->isInstantiable()) {
throw new \Exception("Target [$class] is not instantiable.");
}
$constructor = $reflector->getConstructor();
// If there are no constructor, that means there are no dependencies
if ($constructor === null) {
return new $class;
}
$parameters = $constructor->getParameters();
$dependencies = [];
foreach ($parameters as $parameter) {
$type = $parameter->getType();
if (! $type instanceof \ReflectionNamedType || $type->isBuiltin()) {
// Resolve a non-class hinted primitive dependency.
if ($parameter->isDefaultValueAvailable()) {
$dependencies[] = $parameter->getDefaultValue();
} else if ($parameter->isVariadic()) {
$dependencies[] = [];
} else {
throw new \Exception("Unresolvable dependency [$parameter] in class {$parameter->getDeclaringClass()->getName()}");
}
}
$name = $type->getName();
// Resolve a class based dependency from the container.
try {
$dependency = $this->get($name);
$dependencies[] = $dependency;
} catch (\Exception $e) {
if ($parameter->isOptional()) {
$dependencies[] = $parameter->getDefaultValue();
} else {
$dependency = $this->make($parameter->getType()->getName());
//$this->register($name, $dependency);
$dependencies[] = $dependency;
}
}
}
return $reflector->newInstanceArgs($dependencies);
}
protected function provides(string $id): bool
{
foreach($this->services as $service) {
if($service->provides($id)) {
return true;
}
}
return false;
}
protected function initialize(string $id): void
{
$initialized = false;
foreach ($this->services as $service) {
if (in_array($service->getId(), $this->initializedServices, true)) {
$initialized = true;
continue;
}
if ($service->provides($id)) {
$service->initialize();
$this->initializedServices[] = $service->getId();
$initialized = true;
}
}
if (!$initialized) {
throw new \Exception(
sprintf('(%s) is not provided by a service provider', $id)
);
}
}
}

View file

@ -0,0 +1,79 @@
<?php
namespace Musoka\Zap\ErrorHandling;
use Musoka\Zap\Application;
class Handler
{
protected bool $debugEnabled;
protected array $callbacks = [];
/**
* ErrorHandler constructor.
*
* Can be used in the future to set up loggers and debugging tools.
*/
public function __construct(bool $debug = false)
{
$this->debugEnabled = $debug;
set_error_handler(function (int $level, string $message, ?string $file, ?int $line, ?array $context = null) {
$this->errorHandler($level, $message, $file, $line, $context);
});
set_exception_handler(function (\Throwable $e) {
$this->exceptionHandler($e);
});
register_shutdown_function(function () {
$this->checkShutdownErrors();
});
}
public function register(callable $callback): self
{
$this->callbacks[] = $callback;
return $this;
}
private function errorHandler(int $level, string $message, ?string $file, ?int $line, ?array $context = null)
{
foreach($this->callbacks as $callback) {
if(is_callable($callback)) {
($callback)();
}
}
if($this->debugEnabled) {
$resource = fopen('php://stderr', 'w');
fwrite($resource, "An error occurred! {$message}");
fwrite($resource, "Occurred in {$file}, at line {$line}.");
}
}
private function exceptionHandler(\Throwable $e)
{
foreach($this->callbacks as $callback) {
if(is_callable($callback)) {
($callback)($e);
}
}
if($this->debugEnabled) {
$resource = fopen('php://stderr', 'w');
fwrite($resource, "An error was thrown! {$e->getMessage()}");
fwrite($resource, "{$e->getTraceAsString()}");
}
}
private function checkShutdownErrors(array $error = [])
{
$error = $error ?? error_get_last();
if ($error && $error['type'] === E_ERROR) {
$this->errorHandler($error['type'], $error['message'], $error['file'], $error['line']);
}
}
}

1
src/Helpers/main.php Normal file
View file

@ -0,0 +1 @@
<?php

50
src/Helpers/paths.php Normal file
View file

@ -0,0 +1,50 @@
<?php
if(!function_exists('base_path')) {
function base_path(string $path = ''): string
{
return app()->getBasePath($path);
}
}
if(!function_exists('config_path')) {
function config_path(string $path = ''): string
{
return app()->getConfigPath($path);
}
}
if(!function_exists('lang_path')) {
function lang_path(string $path = ''): string
{
return app()->getLangPath($path);
}
}
if(!function_exists('public_path')) {
function public_path(string $path = ''): string
{
return app()->getPublicPath($path);
}
}
if(!function_exists('storage_path')) {
function storage_path(string $path = ''): string
{
return app()->getStoragePath($path);
}
}
if(!function_exists('resource_path')) {
function resource_path(string $path = ''): string
{
return app()->getResourcePath($path);
}
}
if(!function_exists('view_path')) {
function view_path(string $path = ''): string
{
return app()->getViewPath($path);
}
}

View file

@ -0,0 +1,28 @@
<?php
if(!function_exists('translator')) {
function translator(): \Musoka\Zap\I18n\Translator
{
return app()->get('translator');
}
}
if(!function_exists('translate')) {
function translate(string $key, $default = null)
{
return translator()->getTranslation($key, $default);
}
}
if(!function_exists('get_locale')) {
function get_locale(): string
{
return translator()->getLocale();
}
}
if(!function_exists('get_fallback_locale')) {
function get_fallback_locale(): ?string
{
return translator()->getFallbackLocale();
}
}

1
src/Helpers/urls.php Normal file
View file

@ -0,0 +1 @@
<?php

12
src/Helpers/views.php Normal file
View file

@ -0,0 +1,12 @@
<?php
if(!function_exists('view')) {
function view(string $view, array $data = []): \Musoka\Zap\View\View
{
$factory = app()->get('view');
if($factory) {
return $factory->make($view, $data);
} else {
throw new \Exception('ViewFactory not available as component, cannot use views.');
}
}
}

10
src/Http/Request.php Normal file
View file

@ -0,0 +1,10 @@
<?php
namespace Musoka\Zap\Http;
use Symfony\Component\HttpFoundation\Request as SymfonyRequest;
class Request extends SymfonyRequest
{
}

35
src/Http/Response.php Normal file
View file

@ -0,0 +1,35 @@
<?php
namespace Musoka\Zap\Http;
class Response extends \Symfony\Component\HttpFoundation\Response
{
/**
* Sets the response content.
*
* If an array or instance of ArrayObject is passed, the
* response will be modified to a json response. Otherwise,
* the content will be converted to a string.
*
* @return $this
*/
public function setContent($content): static
{
if($content instanceof \ArrayObject || is_array($content)) {
$this->headers->set('Content-Type', 'application/json', true);
$json = json_encode($content);
if($json === false) {
throw new \InvalidArgumentException(json_last_error_msg());
}
$content = $json;
} elseif(!is_string($content)) {
$content = (string) $content;
}
$this->content = $content ?? '';
return $this;
}
}

36
src/I18n/I18nProvider.php Normal file
View file

@ -0,0 +1,36 @@
<?php
namespace Musoka\Zap\I18n;
use Musoka\Zap\Components\AbstractComponentProvider;
use Musoka\Zap\Components\ComponentAggregator;
class I18nProvider extends AbstractComponentProvider
{
protected array $provides = [
'translator'
];
public function registerComponents(ComponentAggregator $aggregator): void
{
$fallbackLocale = app()->get('config')->get('application.locale.fallback', 'en');
$locale = $this->determineLocaleFromUrl();
$langDirPath = app()->getLangPath();
$translator = new Translator($fallbackLocale, $langDirPath);
$locale = Translator::checkLocale($locale) ? $locale : $fallbackLocale;
$translator->setLocale($locale);
$aggregator->addComponent('translator', $translator);
}
protected function determineLocaleFromUrl()
{
$request = app()->get('request');
if($request) {
return current(explode('/', ltrim($request->getPathInfo(), '/')));
}
return null;
}
}

164
src/I18n/Translator.php Normal file
View file

@ -0,0 +1,164 @@
<?php
namespace Musoka\Zap\I18n;
/**
* Translator class
*
* The Translator class provides an implementation for setting the application's
* locale and providing translations for use in the application ui. This implementation
* uses a regular expression to check if entered locales conform to a standardized format.
*/
class Translator
{
/**
* Regular expression used to check the form of locale strings.
*/
private const LOCALE_REGEX = '/^[A-Za-z]{2,3}([_-][A-Za-z]{4})?([_-][A-Za-z]{2})?$/';
/**
* The default application locale.
* @var string
*/
private string $fallbackLocale;
/**
* The currently active locale.
* @var string
*/
private string $locale;
/**
* The path where translation files are stored.
* @var string
*/
private string $translationsPath;
/**
* The currently loaded translations.
* @var array
*/
private array $translations;
/**
* Initialize a Translator instance.
*
* To initialize a Translator, it must know where translation files are to be found and what
* locale to set by default on initialization. These values are checked for correctness, upon
* failure an exception will be thrown.
*
* @param string $fallbackLocale
* @param string $translationsPath
*/
public function __construct(string $fallbackLocale, string $translationsPath)
{
$this->fallbackLocale = $fallbackLocale;
$this->locale = $this->fallbackLocale;
$this->translationsPath = $translationsPath;
if(!file_exists($translationsPath) || !is_dir($translationsPath)) {
throw new \RuntimeException("Could not initialize translator: invalid translations path.");
}
}
public function provides(): string
{
return 'translator';
}
/**
* Get the default locale string.
* @return string
*/
public function getFallbackLocale(): string
{
return $this->fallbackLocale;
}
/**
* Get the current locale string.
* @return string
*/
public function getLocale(): string
{
return $this->locale;
}
/**
* Set the current locale string. If the form is invalid, an exception will be thrown.
* @param string $locale
* @return $this
*/
public function setLocale(string $locale): static
{
if(!static::checkLocale($locale)) {
throw new \RuntimeException("Could not set locale: invalid locale format.");
}
$this->locale = $locale;
$this->loadTranslations();
return $this;
}
/**
* Get a translation from the translation array.
* @param string $key
* @param $default
* @return mixed|null|string
*/
public function getTranslation(string $key, $default = null)
{
if(empty($this->translations)) {
return $default;
}
$keys = explode ('.', $key);
$value = $this->translations;
foreach($keys as $index) {
if(!key_exists($index, $value)) return $default;
$value = $value[$index];
}
return $value;
}
/**
* Check if a locale string has the correct format.
* @param string $locale
* @return bool
*/
public static function checkLocale(string $locale): bool
{
return (!empty($locale) && preg_match_all(static::LOCALE_REGEX, $locale));
}
/**
* Load translations for the currently selected locale.
* @return void
*/
private function loadTranslations(): void
{
$dir = $this->translationsPath;
$locale = $this->locale;
$localeDir = $dir . DIRECTORY_SEPARATOR . $locale;
if(!file_exists($localeDir)) {
//TODO: is the error really needed?
throw new \RuntimeException("Unable to load translations; translation directory not found.");
}
$files = glob($localeDir . DIRECTORY_SEPARATOR . '*.php');
if($files === false || empty($files)) {
return;
}
$this->translations = [];
foreach($files as $file) {
$translations = require $file;
$key = basename($file, '.php');
$this->translations[$key] = $translations;
}
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace Musoka\Zap\Routing;
trait IsRepository
{
protected $basePattern = '';
/**
* @var array The array with registered routes.
*/
protected array $routes;
/**
* Register a new route.
*
* @param string $pattern The uri pattern to match
* @param array|string $methods One or more HTTP methods to match
* @param string|array|callable $callback A callback to run if this route is called
* @param array $middlewares List of extra middlewares to run for this route
* @return Route
*/
public function register(string $pattern, array|string $methods, string|array|callable $callback, array $middlewares = []): Route
{
$pattern = '/' . trim($this->basePattern . $pattern, '/');
$route = new Route($pattern, $methods, $callback, $middlewares);
foreach($route->getMethods() as $method) {
$this->routes[$method][] = $route;
}
return $route;
}
public function get(string $pattern, string|array|callable $callback, array $middlewares = []): Route
{
return $this->register($pattern, 'GET', $callback, $middlewares);
}
public function post(string $pattern, string|array|callable $callback, array $middlewares = []): Route
{
return $this->register($pattern, 'POST', $callback, $middlewares);
}
public function put(string $pattern, string|array|callable $callback, array $middlewares = []): Route
{
return $this->register($pattern, 'PUT', $callback, $middlewares);
}
public function delete(string $pattern, string|array|callable $callback, array $middlewares = []): Route
{
return $this->register($pattern, 'DELETE', $callback, $middlewares);
}
public function options(string $pattern, string|array|callable $callback, array $middlewares = []): Route
{
return $this->register($pattern, 'OPTIONS', $callback, $middlewares);
}
public function patch(string $pattern, string|array|callable $callback, array $middlewares = []): Route
{
return $this->register($pattern, 'PATCH', $callback, $middlewares);
}
public function head(string $pattern, string|array|callable $callback, array $middlewares = []): Route
{
return $this->register($pattern, 'HEAD', $callback, $middlewares);
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace Musoka\Zap\Routing;
class LocalizedRepository extends Repository
{
public function __construct()
{
parent::__construct();
}
public function register(string $pattern, array|string $methods, string|array|callable $callback, array $middlewares = []): Route
{
$pattern = '/' . trim($pattern, '/');
$route = new LocalizedRoute($pattern, $methods, $callback, $middlewares);
foreach($route->getMethods() as $method) {
$this->routes[$method][] = $route;
}
return $route;
}
}

View file

@ -0,0 +1,21 @@
<?php
namespace Musoka\Zap\Routing;
class LocalizedRoute extends Route
{
/**
* @var string[]
*/
protected $translatedPatterns;
public function __construct(string $pattern, array|string $methods, callable|array|string $callback, array $supportedLocales = [], array $middlewares = [])
{
parent::__construct($pattern, $methods, $callback, $middlewares);
foreach($supportedLocales as $locale) {
$this->translatedPatterns[$locale] = $pattern;
}
}
}

113
src/Routing/Repository.php Normal file
View file

@ -0,0 +1,113 @@
<?php
namespace Musoka\Zap\Routing;
class Repository
{
/**
* @var array The array with registered routes.
*/
protected array $routes;
/**
* RoutingRepository constructor.
*/
public function __construct()
{
$this->routes = [];
}
/**
* Create a group of localized routes.
*
* Group a number of routes as localized routes. The callback function will
* get an instance of LocalizedRepository passed to it, which will expose
* extra functions for localization support.
*
* @param callable $callback
* @return void
*/
public function localized(callable $callback): void
{
$localizedGroup = new LocalizedRepository();
call_user_func($callback, $localizedGroup);
//Add localized routes to list
$this->routes = array_merge_recursive($this->routes, $localizedGroup->collectRoutes());
}
/**
* Register a new route.
*
* @param string $pattern The uri pattern to match
* @param array|string $methods One or more HTTP methods to match
* @param string|array|callable $callback A callback to run if this route is called
* @param array $middlewares List of extra middlewares to run for this route
* @return Route
*/
public function register(string $pattern, array|string $methods, string|array|callable $callback, array $middlewares = []): Route
{
$pattern = '/' . trim($pattern, '/');
$route = new Route($pattern, $methods, $callback, $middlewares);
foreach($route->getMethods() as $method) {
$this->routes[$method][] = $route;
}
return $route;
}
public function get(string $pattern, string|array|callable $callback, array $middlewares = []): Route
{
return $this->register($pattern, 'GET', $callback, $middlewares);
}
public function post(string $pattern, string|array|callable $callback, array $middlewares = []): Route
{
return $this->register($pattern, 'POST', $callback, $middlewares);
}
public function put(string $pattern, string|array|callable $callback, array $middlewares = []): Route
{
return $this->register($pattern, 'PUT', $callback, $middlewares);
}
public function delete(string $pattern, string|array|callable $callback, array $middlewares = []): Route
{
return $this->register($pattern, 'DELETE', $callback, $middlewares);
}
public function options(string $pattern, string|array|callable $callback, array $middlewares = []): Route
{
return $this->register($pattern, 'OPTIONS', $callback, $middlewares);
}
public function patch(string $pattern, string|array|callable $callback, array $middlewares = []): Route
{
return $this->register($pattern, 'PATCH', $callback, $middlewares);
}
public function head(string $pattern, string|array|callable $callback, array $middlewares = []): Route
{
return $this->register($pattern, 'HEAD', $callback, $middlewares);
}
/**
* Get the list of registered routes.
*
* @return array
*/
public function collectRoutes(): array
{
//Loop through groups and register their routes
/*foreach($this->groups as $group) {
$group(); //Invoke group to run its callback
$groupRoutes = $group->getRoutes();
//Merge into main routes list.
$this->routes = array_merge_recursive($groupRoutes, $this->routes);
}*/
return $this->routes;
}
}

260
src/Routing/Route.php Normal file
View file

@ -0,0 +1,260 @@
<?php
namespace Musoka\Zap\Routing;
class Route
{
/**
* @var string Name for the route. Multple routes can have the same name, linking them.
*/
private string $name;
/**
* @var string Pattern for this route.
*/
private string $pattern;
/**
* @var string|array|callable The callback to run when executing the route.
*/
private $callback;
/**
* @var array<string> The HTTP verbs applicable to this route.
*/
private array $methods = [];
/**
* @var array<string> A list of middlewares that should be run before executing this route.
*/
private array $middlewares = [];
/**
* @var array<string> List of variables taken from the pattern. These are used to pass parameters to the callback function.
*/
private array $vars = [];
/**
* Route constructor.
*
* The constructor will initialize a Route object with a pattern, list of applicable HTTP methods and the callback
* to run for this route. Optionally a list of middlewares to run can also be passed (but can also be set later).
*/
public function __construct(string $pattern, string|array $methods, string|array|callable $callback, array $middlewares = [])
{
if ($methods === []) {
throw new \InvalidArgumentException('HTTP methods argument was empty; must contain at least one method');
}
if(is_string($methods)) $methods = [$methods];
foreach($methods as $key => $method) {
$methods[$key] = strtoupper($method);
if (!in_array($methods[$key], ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH', 'HEAD'])) {
throw new \InvalidArgumentException("Invalid method '{$method}' specified.");
}
}
$this->pattern = $pattern;
$this->callback = $callback;
$this->methods = $methods;
$this->middlewares = $middlewares;
}
/**
* Get the name for the route.
*
* @return string
*/
public function getName(): string
{
return $this->name;
}
/**
* Set the name for the route.
*
* @param string $name
* @return $this
*/
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
/**
* Get the url pattern for the route.
*
* @return string
*/
public function getPattern(): string
{
return $this->pattern;
}
public function setPattern(string $pattern): static
{
$this->pattern = $pattern;
return $this;
}
/**
* Get the callback function for this route.
*
* @return string|array|callable
*/
public function getCallback(): string|array|callable
{
return $this->callback;
}
/**
* Set the callback function for this route.
*
* @param string|array|callable $callback
* @return $this
*/
public function setCallback(string|array|callable $callback): static
{
$this->callback = $callback;
return $this;
}
/**
* Get the HTTP methods applicable to this route.
*
* @return string[]
*/
public function getMethods(): array
{
return $this->methods;
}
/**
* Set the HTTP methods applicable to this route.
*
* @param string|string[] $methods
* @return $this
*/
public function setMethods(string|array $methods): static
{
if(is_string($methods)) {
$methods = [$methods];
}
$this->methods = $methods;
return $this;
}
/**
* Get the list of middlewares to be run for this route.
*
* @return string[]
*/
public function getMiddlewares(): array
{
return $this->middlewares;
}
public function setMiddlewares(array $middlewares): static
{
$this->middlewares = $middlewares;
return $this;
}
public function addMiddleware(string|array $middleware): static
{
if(is_string($middleware)) {
$middleware = [$middleware];
}
foreach($middleware as $value) {
if(!in_array($value, $this->middlewares)) {
$this->middlewares[] = $value;
}
}
return $this;
}
/**
* @return array
*/
public function getVarsNames(): array
{
preg_match_all('/{[^}]*}/', $this->pattern, $matches);
return reset($matches) ?? [];
}
/**
* @return bool
*/
public function hasVars(): bool
{
return $this->getVarsNames() !== [];
}
/**
* @return string[]
*/
public function getVars(): array
{
return $this->vars;
}
/**
* @param string $path
* @param string $method
* @return bool
*/
public function match(string $path, string $method): bool
{
$regex = $this->getPattern();
foreach ($this->getVarsNames() as $variable) {
$varName = trim($variable, '{\}');
$regex = str_replace($variable, '(?P<' . $varName . '>[^/]++)', $regex);
}
if (in_array($method, $this->getMethods()) && preg_match('#^' . $regex . '$#sD', self::trimPath($path), $matches)) {
$values = array_filter($matches, static function ($key) {
return is_string($key);
}, ARRAY_FILTER_USE_KEY);
foreach ($values as $key => $value) {
$this->vars[$key] = $value;
}
return true;
}
return false;
}
/**
* @return mixed
*/
public function run()
{
//TODO: middlewares
//TODO: allow for automatically injecting dependencies for function arguments
if(is_callable($this->callback)) {
return call_user_func_array($this->callback, $this->vars);
} elseif (is_array($this->callback) && [0,1] == array_keys($this->callback)) {
$class = $this->callback[0];
$method = $this->callback[1];
if(class_exists($class) && method_exists($class, $method)) {
$controller = new $class();
return $controller->$method(...$this->vars);
}
}
throw new \RuntimeException("Could not resolve callable function for route.");
}
/**
* @param string $path
* @return string
*/
protected static function trimPath(string $path): string
{
return '/' . rtrim(ltrim(trim($path), '/'), '/');
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace Musoka\Zap\Routing;
class RouteGroup
{
use IsRepository;
/**
* @var callable Callback to call to actually register the routes.
*/
protected $callback;
public function __construct(string $pattern, callable $callback)
{
$this->basePattern = $pattern; //Group pattern is used as base pattern for all routes in group.
$this->callback = $callback;
$this->routes = [];
}
public function __invoke(): void
{
($this->callback)($this);
}
public function getPattern(): string
{
return $this->basePattern;
}
public function getCallback(): callable
{
return $this->callback;
}
public function getRoutes(): array
{
return $this->routes;
}
}

View file

@ -0,0 +1,113 @@
<?php
namespace Musoka\Zap\Routing;
class RouteRepository
{
/**
* @var array The array with registered routes.
*/
protected array $routes;
/**
* RoutingRepository constructor.
*/
public function __construct()
{
$this->routes = [];
}
/**
* Create a group of localized routes.
*
* Group a number of routes as localized routes. The callback function will
* get an instance of LocalizedRepository passed to it, which will expose
* extra functions for localization support.
*
* @param callable $callback
* @return void
*/
/*public function localized(callable $callback): void
{
$localizedGroup = new LocalizedRepository();
call_user_func($callback, $localizedGroup);
//Add localized routes to list
$this->routes = array_merge_recursive($this->routes, $localizedGroup->collectRoutes());
}*/
/**
* Register a new route.
*
* @param string $pattern The uri pattern to match
* @param array|string $methods One or more HTTP methods to match
* @param string|array|callable $callback A callback to run if this route is called
* @param array $middlewares List of extra middlewares to run for this route
* @return Route
*/
public function register(string $pattern, array|string $methods, string|array|callable $callback, array $middlewares = []): Route
{
$pattern = '/' . trim($pattern, '/');
$route = new Route($pattern, $methods, $callback, $middlewares);
foreach($route->getMethods() as $method) {
$this->routes[$method][] = $route;
}
return $route;
}
public function get(string $pattern, string|array|callable $callback, array $middlewares = []): Route
{
return $this->register($pattern, 'GET', $callback, $middlewares);
}
public function post(string $pattern, string|array|callable $callback, array $middlewares = []): Route
{
return $this->register($pattern, 'POST', $callback, $middlewares);
}
public function put(string $pattern, string|array|callable $callback, array $middlewares = []): Route
{
return $this->register($pattern, 'PUT', $callback, $middlewares);
}
public function delete(string $pattern, string|array|callable $callback, array $middlewares = []): Route
{
return $this->register($pattern, 'DELETE', $callback, $middlewares);
}
public function options(string $pattern, string|array|callable $callback, array $middlewares = []): Route
{
return $this->register($pattern, 'OPTIONS', $callback, $middlewares);
}
public function patch(string $pattern, string|array|callable $callback, array $middlewares = []): Route
{
return $this->register($pattern, 'PATCH', $callback, $middlewares);
}
public function head(string $pattern, string|array|callable $callback, array $middlewares = []): Route
{
return $this->register($pattern, 'HEAD', $callback, $middlewares);
}
/**
* Get the list of registered routes.
*
* @return array
*/
public function collectRoutes(): array
{
//Loop through groups and register their routes
/*foreach($this->groups as $group) {
$group(); //Invoke group to run its callback
$groupRoutes = $group->getRoutes();
//Merge into main routes list.
$this->routes = array_merge_recursive($groupRoutes, $this->routes);
}*/
return $this->routes;
}
}

179
src/Routing/Router.php Normal file
View file

@ -0,0 +1,179 @@
<?php
namespace Musoka\Zap\Routing;
use Musoka\Zap\Application;
use Symfony\Component\HttpFoundation\HeaderBag;
use Musoka\Zap\Http\Request;
class Router implements RouterInterface
{
private Request $request;
protected RouteRepository $repository;
private ?Route $currentRoute;
/**
* @var array
*/
private array $routes;
/**
* @var string
*/
private string $requestUri;
/**
* @var string
*/
private string $requestMethod;
/**
* @var array
*/
private HeaderBag $requestHeaders;
/**
* @param Application $app
*/
public function __construct(Request $request, string $routeDirPath, string $cacheDirPath)
{
$this->request = $request;
$this->currentRoute = null;
//Load routes from cache, or fall back to loading from file
if(!$this->loadFromCache($cacheDirPath)) {
$this->loadFromRouteFile($routeDirPath);
}
}
protected function loadFromCache(string $cachePath): bool
{
if(!file_exists($cachePath) || !file_exists($cachePath . DIRECTORY_SEPARATOR . 'route-cache.php')) {
return false;
}
$this->routes = require $cachePath . DIRECTORY_SEPARATOR . 'route-cache.php';
return true;
}
protected function loadFromRouteFile(string $routePath): void
{
if(!file_exists($routePath) || !file_exists($routePath . DIRECTORY_SEPARATOR . 'routes.php')) {
throw new \RuntimeException('Unable to initialize routes: route file not found.');
}
$this->repository = new RouteRepository();
$routes = require $routePath . DIRECTORY_SEPARATOR . 'routes.php';
if(!is_callable($routes)) {
throw new \RuntimeException('Unable to initialize routes: route file does not contain callable function.');
}
//Initialize routes
call_user_func($routes, $this->repository);
$this->routes = $this->repository->collectRoutes();
}
protected function readRequestData()
{
$uri = $this->request->getPathInfo();
// Remove trailing slash + enforce a slash at the start
$this->requestUri = '/' . trim($uri, '/');
$this->requestMethod = $this->request->getMethod();
$this->requestHeaders = $this->request->headers;
// If it's a HEAD request override it to being GET and prevent any output, as per HTTP Specification
// @url http://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.4
/*if ($_SERVER['REQUEST_METHOD'] == 'HEAD') {
ob_start();
$method = 'GET';
}*/ //TODO: should this be done in application?
/*$headers = [];*/
// If getallheaders() is available, use that
/*if (function_exists('getallheaders')) {
$headers = getallheaders();
// getallheaders() can return false if something went wrong
if ($headers !== false) {
$this->requestHeaders = $headers;
return $headers;
}
}
// Method getallheaders() not available or went wrong: manually extract them
foreach ($_SERVER as $name => $value) {
if ((substr($name, 0, 5) == 'HTTP_') || ($name == 'CONTENT_TYPE') || ($name == 'CONTENT_LENGTH')) {
$headers[str_replace([' ', 'Http'], ['-', 'HTTP'], ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;
}
}*/
}
protected function findRoute(string $path, string $method): ?Route
{
$method = strtoupper($method);
if(key_exists($method, $this->routes)) {
foreach($this->routes[$method] as $route) {
if($route->match($path, $method)) {
return $route;
}
}
}
return null;
}
protected function handleRoute(Route $route)
{
//Todo: run route middleware and route handler.
//Maybe make middleware part of a base controller class?
return $route->run();
}
/**
* @return mixed|bool Returns a mixed response, or a boolean false if there was no valid route.
*/
public function run()
{
//Parse request
$this->readRequestData();
//Find the first matching route. Only one route will ever be executed.
$route = $this->findRoute($this->requestUri, $this->requestMethod);
//If a route is found, handle it and return its response.
//Otherwise, return a boolean false to denote no route could be found.
if($route) {
$this->currentRoute = $route;
return $this->handleRoute($route);
} else {
return false;
}
//If the request was a HEAD request, empty the output buffer.
/*if ($_SERVER['REQUEST_METHOD'] == 'HEAD') {
ob_end_clean();
}*/ //TODO: should this be done in application?
}
public function buildCache()
{
}
public function getCurrentRoute(): ?Route
{
return $this->currentRoute;
}
public function getRoute(string $pattern, ?string $method): ?Route
{
return $this->findRoute($pattern, $method ?? 'GET');
}
}

View file

@ -0,0 +1,8 @@
<?php
namespace Musoka\Zap\Routing;
interface RouterInterface
{
public function run();
}

View file

@ -0,0 +1,28 @@
<?php
namespace Musoka\Zap\Routing;
use Musoka\Zap\Components\AbstractComponentProvider;
use Musoka\Zap\Components\ComponentAggregator;
class RouterProvider extends AbstractComponentProvider
{
protected array $provides = [
'router',
'url'
];
public function registerComponents(ComponentAggregator $aggregator): void
{
$request = $aggregator->get('request');
$routeDirPath = realpath(app()->getBasePath('routes'));
$cacheDirPath = realpath(app()->getCachePath());
$router = new Router($request, $routeDirPath, $cacheDirPath);
//TODO: set up URL generator
$aggregator->addComponent('router', $router);
$aggregator->addComponent('url', function () { return null; });
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace Musoka\Zap\Routing;
use Musoka\Zap\I18n\Translator;
use Symfony\Component\HttpFoundation\Request;
class UrlGenerator
{
protected $translator;
protected $routes;
protected $request;
protected $baseUrl;
public function __construct(string $baseUrl, Request $request, Translator $translator)
{
$this->request = $request;
$this->translator = $translator;
$this->baseUrl = $baseUrl ?? $request->getBaseUrl();
}
public function getBaseUrl(string $path = '', array $data = [], ?string $locale = null): string
{
$overrideLocale = $locale ?? $this->translator->getLocale();
$localePrefix = ($overrideLocale != $this->translator->getDefaultLocale())
? $overrideLocale . DIRECTORY_SEPARATOR
: '';
$url = $this->baseUrl . $localePrefix . ($path ? DIRECTORY_SEPARATOR.$path : '');
//Attach data as a query string.
if(!empty($data)) {
}
return $url;
}
public function getUrl(string $path = '', array $data = [], ?string $locale = null): string
{
return $this->getBaseUrl($path, $data, $locale);
}
public function getStorageUrl(string $path = '', array $data = [], ?string $locale = null): string
{
return '';
}
public function getAssetUrl(string $path = '', array $data = [], ?string $locale = null): string
{
return '';
}
public function getRouteUrl(string $route = '', array $data = [], ?string $locale = null): string
{
return '';
}
public function setRoutes(array $routes): bool
{
if(!empty($this->routes)) {
$this->routes = $routes;
return true;
}
return false;
}
}

18
src/View/Renderable.php Normal file
View file

@ -0,0 +1,18 @@
<?php
namespace Musoka\Zap\View;
interface Renderable
{
/**
* Convert the object state into a string representation.
* @return string
*/
public function __toString(): string;
/**
* Render the object as a string.
* @return string
*/
public function render(): string;
}

186
src/View/View.php Normal file
View file

@ -0,0 +1,186 @@
<?php
namespace Musoka\Zap\View;
enum SectionMode {
case OVERWRITE;
case PREPEND;
case APPEND;
}
class View implements Renderable
{
protected ViewFactory $factory;
protected string $view;
protected array $data;
protected array $sections = [];
protected ?string $sectionName;
protected ?SectionMode $sectionMode;
protected ?string $extendView;
protected ?array $extendData;
public function __construct(ViewFactory $factory, string $view, array $data = [])
{
$this->factory = $factory;
$this->view = $view;
$this->data = $data;
}
/**
* Set data that should be made available to the view.
*
* Consecutive calls will append data, overwriting existing
* data if keys were already set.
*
* @param array|null $data
* @return array|void
*/
public function data(array $data = null)
{
if (is_null($data)) {
return $this->data;
}
$this->data = array_merge($this->data, $data);
}
/**
* Render the view object to a string.
*
* @return string
* @throws \Throwable
*/
public function __toString(): string
{
return $this->render();
}
/**
* Render the view to a string.
*
* @param array $data
* @return string
* @throws \Throwable
*/
public function render(array $data = []): string
{
$this->data($data);
$viewPath = $this->factory->resolveViewPath($this->view);
try {
$level = ob_get_level();
ob_start();
(function() {
extract($this->data);
include func_get_arg(0);
})($viewPath);
$content = ob_get_clean();
if (isset($this->extendView)) {
$extend = $this->factory->make($this->extendView);
$extend->sections = array_merge($this->sections, ['content' => $content]);
$content = $extend->render($this->extendData);
}
return $content;
}
catch(\Throwable $e) {
while (ob_get_level() > $level) {
ob_end_clean();
}
throw $e;
}
}
public function extends(string $view, array $data = []): void
{
$this->extendView = $view;
$this->extendData = $data;
}
public function insert(string $view, array $data = []): string
{
return $this->factory->make($view, $data)->render();
}
public function section(string $name, string $default = ''): string
{
return $this->sections[$name] ?? $default;
}
public function startSection(string $name, SectionMode $mode = SectionMode::OVERWRITE): void
{
$this->sectionMode = $mode;
if ($name === 'content') {
throw new \LogicException(
'The section name "content" is reserved.'
);
}
if (isset($this->sectionName) && !empty($this->sectionName)) {
throw new \LogicException('You cannot nest sections within other sections.');
}
$this->sectionName = $name;
ob_start();
}
public function appendSection(string $name): void
{
$this->startSection($name, SectionMode::APPEND);
}
public function prependSection(string $name): void
{
$this->startSection($name, SectionMode::PREPEND);
}
public function endSection(): void
{
if (is_null($this->sectionName)) {
throw new \LogicException(
'You must start a section before you can stop it.'
);
}
if (!isset($this->sections[$this->sectionName])) {
$this->sections[$this->sectionName] = '';
}
switch ($this->sectionMode) {
case SectionMode::OVERWRITE:
$this->sections[$this->sectionName] = ob_get_clean();
break;
case SectionMode::APPEND:
$this->sections[$this->sectionName] .= ob_get_clean();
break;
case SectionMode::PREPEND:
$this->sections[$this->sectionName] = ob_get_clean().$this->sections[$this->sectionName];
break;
}
$this->sectionName = null;
$this->sectionMode = null;
}
public function escape(string $string): string
{
return htmlspecialchars($string);
}
public function e(string $string): string
{
return $this->escape($string);
}
}

58
src/View/ViewFactory.php Normal file
View file

@ -0,0 +1,58 @@
<?php
namespace Musoka\Zap\View;
class ViewFactory
{
protected string $viewDir;
public function __construct(string $viewDir)
{
$this->viewDir = $viewDir;
if(!file_exists($this->viewDir)) {
throw new \RuntimeException('View directory must be a valid existing directory.');
}
}
public function resolveViewPath(string $view): string
{
$viewPath = $this->viewDir . DIRECTORY_SEPARATOR . ltrim($view, '/');
if(is_dir($viewPath)) {
$indexPath = rtrim($viewPath, '/') . DIRECTORY_SEPARATOR . 'index.php';
if(file_exists($indexPath)) return $indexPath;
} else {
if(file_exists($viewPath)) return $viewPath;
elseif(file_exists($viewPath . '.php')) return $viewPath . '.php';
}
throw new \RuntimeException("View {$viewPath} could not be found.");
}
/**
* Check if a template exists.
* @param string $view
* @return boolean
*/
public function exists(string $view): bool
{
try {
$this->resolveViewPath($view);
return true;
}
catch (\RuntimeException $e) {
return false;
}
}
/**
* Create a new template.
* @param string $view
* @param array $data
* @return View
*/
public function make(string $view, array $data = []): View
{
return new View($this, $view, $data);
}
}

21
src/View/ViewProvider.php Normal file
View file

@ -0,0 +1,21 @@
<?php
namespace Musoka\Zap\View;
use Musoka\Zap\Components\AbstractComponentProvider;
use Musoka\Zap\Components\ComponentAggregator;
class ViewProvider extends AbstractComponentProvider
{
protected array $provides = [
'view'
];
public function registerComponents(ComponentAggregator $aggregator): void
{
$viewDirPath = app()->getViewPath();
$viewFactory = new ViewFactory($viewDirPath);
$aggregator->addComponent('view', $viewFactory);
}
}

12
src/helpers.php Normal file
View file

@ -0,0 +1,12 @@
<?php
if(!function_exists('app')) {
function app(): ?\Musoka\Zap\Application
{
return \Musoka\Zap\Application::getInstance();
}
}
require_once __DIR__ . DIRECTORY_SEPARATOR . 'Helpers' . DIRECTORY_SEPARATOR . 'paths.php';
require_once __DIR__ . DIRECTORY_SEPARATOR . 'Helpers' . DIRECTORY_SEPARATOR . 'views.php';
require_once __DIR__ . DIRECTORY_SEPARATOR . 'Helpers' . DIRECTORY_SEPARATOR . 'translator.php';