From b1aa1608c38192bca856f2451d542acacaf95529 Mon Sep 17 00:00:00 2001 From: Fristi Date: Sun, 19 May 2024 15:45:12 +0200 Subject: [PATCH] Initial commit --- .gitignore | 7 + .phpstorm.meta.php | 10 + LICENSE | 14 + README.md | 1 + composer.json | 25 ++ src/Application.php | 228 +++++++++++++++ src/Components/AbstractComponentProvider.php | 18 ++ src/Components/BootableProviderInterface.php | 8 + src/Components/ComponentAggregator.php | 98 +++++++ src/Components/ComponentProviderInterface.php | 12 + src/Config/ConfigRepository.php | 66 +++++ src/DIContainer.php | 156 +++++++++++ src/ErrorHandling/Handler.php | 79 ++++++ src/Helpers/main.php | 1 + src/Helpers/paths.php | 50 ++++ src/Helpers/translator.php | 28 ++ src/Helpers/urls.php | 1 + src/Helpers/views.php | 12 + src/Http/Request.php | 10 + src/Http/Response.php | 35 +++ src/I18n/I18nProvider.php | 36 +++ src/I18n/Translator.php | 164 +++++++++++ src/Routing/IsRepository.php | 69 +++++ src/Routing/LocalizedRepository.php | 26 ++ src/Routing/LocalizedRoute.php | 21 ++ src/Routing/Repository.php | 113 ++++++++ src/Routing/Route.php | 260 ++++++++++++++++++ src/Routing/RouteGroup.php | 40 +++ src/Routing/RouteRepository.php | 113 ++++++++ src/Routing/Router.php | 179 ++++++++++++ src/Routing/RouterInterface.php | 8 + src/Routing/RouterProvider.php | 28 ++ src/Routing/UrlGenerator.php | 71 +++++ src/View/Renderable.php | 18 ++ src/View/View.php | 186 +++++++++++++ src/View/ViewFactory.php | 58 ++++ src/View/ViewProvider.php | 21 ++ src/helpers.php | 12 + 38 files changed, 2282 insertions(+) create mode 100644 .gitignore create mode 100644 .phpstorm.meta.php create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 src/Application.php create mode 100644 src/Components/AbstractComponentProvider.php create mode 100644 src/Components/BootableProviderInterface.php create mode 100644 src/Components/ComponentAggregator.php create mode 100644 src/Components/ComponentProviderInterface.php create mode 100644 src/Config/ConfigRepository.php create mode 100644 src/DIContainer.php create mode 100644 src/ErrorHandling/Handler.php create mode 100644 src/Helpers/main.php create mode 100644 src/Helpers/paths.php create mode 100644 src/Helpers/translator.php create mode 100644 src/Helpers/urls.php create mode 100644 src/Helpers/views.php create mode 100644 src/Http/Request.php create mode 100644 src/Http/Response.php create mode 100644 src/I18n/I18nProvider.php create mode 100644 src/I18n/Translator.php create mode 100644 src/Routing/IsRepository.php create mode 100644 src/Routing/LocalizedRepository.php create mode 100644 src/Routing/LocalizedRoute.php create mode 100644 src/Routing/Repository.php create mode 100644 src/Routing/Route.php create mode 100644 src/Routing/RouteGroup.php create mode 100644 src/Routing/RouteRepository.php create mode 100644 src/Routing/Router.php create mode 100644 src/Routing/RouterInterface.php create mode 100644 src/Routing/RouterProvider.php create mode 100644 src/Routing/UrlGenerator.php create mode 100644 src/View/Renderable.php create mode 100644 src/View/View.php create mode 100644 src/View/ViewFactory.php create mode 100644 src/View/ViewProvider.php create mode 100644 src/helpers.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c370d00 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.idea/ + +/cache/ +composer.lock +deploy.sh +deploy-excludes +/vendor/ diff --git a/.phpstorm.meta.php b/.phpstorm.meta.php new file mode 100644 index 0000000..3ee4261 --- /dev/null +++ b/.phpstorm.meta.php @@ -0,0 +1,10 @@ + Musoka\Zap\Routing\Router::class + ])); +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..382ceab --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d1c8b6 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..64506df --- /dev/null +++ b/composer.json @@ -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" + } +} diff --git a/src/Application.php b/src/Application.php new file mode 100644 index 0000000..6027aa9 --- /dev/null +++ b/src/Application.php @@ -0,0 +1,228 @@ +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); + } +} \ No newline at end of file diff --git a/src/Components/AbstractComponentProvider.php b/src/Components/AbstractComponentProvider.php new file mode 100644 index 0000000..ed9f7eb --- /dev/null +++ b/src/Components/AbstractComponentProvider.php @@ -0,0 +1,18 @@ +provides); + } + + public function provides(): array + { + return $this->provides; + } +} \ No newline at end of file diff --git a/src/Components/BootableProviderInterface.php b/src/Components/BootableProviderInterface.php new file mode 100644 index 0000000..322643d --- /dev/null +++ b/src/Components/BootableProviderInterface.php @@ -0,0 +1,8 @@ +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; + } + } +} \ No newline at end of file diff --git a/src/Components/ComponentProviderInterface.php b/src/Components/ComponentProviderInterface.php new file mode 100644 index 0000000..809ea55 --- /dev/null +++ b/src/Components/ComponentProviderInterface.php @@ -0,0 +1,12 @@ +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 + } +} \ No newline at end of file diff --git a/src/DIContainer.php b/src/DIContainer.php new file mode 100644 index 0000000..f267076 --- /dev/null +++ b/src/DIContainer.php @@ -0,0 +1,156 @@ +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) + ); + } + } +} \ No newline at end of file diff --git a/src/ErrorHandling/Handler.php b/src/ErrorHandling/Handler.php new file mode 100644 index 0000000..5594c6c --- /dev/null +++ b/src/ErrorHandling/Handler.php @@ -0,0 +1,79 @@ +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']); + } + } +} \ No newline at end of file diff --git a/src/Helpers/main.php b/src/Helpers/main.php new file mode 100644 index 0000000..b3d9bbc --- /dev/null +++ b/src/Helpers/main.php @@ -0,0 +1 @@ +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); + } +} \ No newline at end of file diff --git a/src/Helpers/translator.php b/src/Helpers/translator.php new file mode 100644 index 0000000..87cf131 --- /dev/null +++ b/src/Helpers/translator.php @@ -0,0 +1,28 @@ +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(); + } +} \ No newline at end of file diff --git a/src/Helpers/urls.php b/src/Helpers/urls.php new file mode 100644 index 0000000..b3d9bbc --- /dev/null +++ b/src/Helpers/urls.php @@ -0,0 +1 @@ +get('view'); + if($factory) { + return $factory->make($view, $data); + } else { + throw new \Exception('ViewFactory not available as component, cannot use views.'); + } + } +} \ No newline at end of file diff --git a/src/Http/Request.php b/src/Http/Request.php new file mode 100644 index 0000000..cfc938b --- /dev/null +++ b/src/Http/Request.php @@ -0,0 +1,10 @@ +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; + } +} \ No newline at end of file diff --git a/src/I18n/I18nProvider.php b/src/I18n/I18nProvider.php new file mode 100644 index 0000000..19f62e4 --- /dev/null +++ b/src/I18n/I18nProvider.php @@ -0,0 +1,36 @@ +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; + } +} \ No newline at end of file diff --git a/src/I18n/Translator.php b/src/I18n/Translator.php new file mode 100644 index 0000000..e12e565 --- /dev/null +++ b/src/I18n/Translator.php @@ -0,0 +1,164 @@ +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; + } + } + +} \ No newline at end of file diff --git a/src/Routing/IsRepository.php b/src/Routing/IsRepository.php new file mode 100644 index 0000000..663c878 --- /dev/null +++ b/src/Routing/IsRepository.php @@ -0,0 +1,69 @@ +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); + } +} \ No newline at end of file diff --git a/src/Routing/LocalizedRepository.php b/src/Routing/LocalizedRepository.php new file mode 100644 index 0000000..88473c0 --- /dev/null +++ b/src/Routing/LocalizedRepository.php @@ -0,0 +1,26 @@ +getMethods() as $method) { + $this->routes[$method][] = $route; + } + + return $route; + } +} \ No newline at end of file diff --git a/src/Routing/LocalizedRoute.php b/src/Routing/LocalizedRoute.php new file mode 100644 index 0000000..a469a01 --- /dev/null +++ b/src/Routing/LocalizedRoute.php @@ -0,0 +1,21 @@ +translatedPatterns[$locale] = $pattern; + } + } +} \ No newline at end of file diff --git a/src/Routing/Repository.php b/src/Routing/Repository.php new file mode 100644 index 0000000..3cce944 --- /dev/null +++ b/src/Routing/Repository.php @@ -0,0 +1,113 @@ +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; + } +} \ No newline at end of file diff --git a/src/Routing/Route.php b/src/Routing/Route.php new file mode 100644 index 0000000..76b4917 --- /dev/null +++ b/src/Routing/Route.php @@ -0,0 +1,260 @@ + The HTTP verbs applicable to this route. + */ + private array $methods = []; + + /** + * @var array A list of middlewares that should be run before executing this route. + */ + private array $middlewares = []; + + /** + * @var array 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), '/'), '/'); + } +} \ No newline at end of file diff --git a/src/Routing/RouteGroup.php b/src/Routing/RouteGroup.php new file mode 100644 index 0000000..ce3bf73 --- /dev/null +++ b/src/Routing/RouteGroup.php @@ -0,0 +1,40 @@ +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; + } +} \ No newline at end of file diff --git a/src/Routing/RouteRepository.php b/src/Routing/RouteRepository.php new file mode 100644 index 0000000..9f066a8 --- /dev/null +++ b/src/Routing/RouteRepository.php @@ -0,0 +1,113 @@ +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; + } +} \ No newline at end of file diff --git a/src/Routing/Router.php b/src/Routing/Router.php new file mode 100644 index 0000000..e77abfc --- /dev/null +++ b/src/Routing/Router.php @@ -0,0 +1,179 @@ +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'); + } +} \ No newline at end of file diff --git a/src/Routing/RouterInterface.php b/src/Routing/RouterInterface.php new file mode 100644 index 0000000..4e69933 --- /dev/null +++ b/src/Routing/RouterInterface.php @@ -0,0 +1,8 @@ +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; }); + } +} \ No newline at end of file diff --git a/src/Routing/UrlGenerator.php b/src/Routing/UrlGenerator.php new file mode 100644 index 0000000..c10f7e9 --- /dev/null +++ b/src/Routing/UrlGenerator.php @@ -0,0 +1,71 @@ +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; + } +} \ No newline at end of file diff --git a/src/View/Renderable.php b/src/View/Renderable.php new file mode 100644 index 0000000..d9068ce --- /dev/null +++ b/src/View/Renderable.php @@ -0,0 +1,18 @@ +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); + } +} \ No newline at end of file diff --git a/src/View/ViewFactory.php b/src/View/ViewFactory.php new file mode 100644 index 0000000..68aced3 --- /dev/null +++ b/src/View/ViewFactory.php @@ -0,0 +1,58 @@ +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); + } +} \ No newline at end of file diff --git a/src/View/ViewProvider.php b/src/View/ViewProvider.php new file mode 100644 index 0000000..5aaf1af --- /dev/null +++ b/src/View/ViewProvider.php @@ -0,0 +1,21 @@ +getViewPath(); + $viewFactory = new ViewFactory($viewDirPath); + + $aggregator->addComponent('view', $viewFactory); + } +} \ No newline at end of file diff --git a/src/helpers.php b/src/helpers.php new file mode 100644 index 0000000..704b219 --- /dev/null +++ b/src/helpers.php @@ -0,0 +1,12 @@ +