src/App.php
<?php
declare(strict_types=1);
namespace Atk4\Ui;
use Atk4\Core\AppScopeTrait;
use Atk4\Core\DiContainerTrait;
use Atk4\Core\DynamicMethodTrait;
use Atk4\Core\ExceptionRenderer;
use Atk4\Core\Factory;
use Atk4\Core\HookTrait;
use Atk4\Core\TraitUtil;
use Atk4\Core\WarnDynamicPropertyTrait;
use Atk4\Data\Persistence;
use Atk4\Ui\Exception\ExitApplicationError;
use Atk4\Ui\Exception\LateOutputError;
use Atk4\Ui\Exception\UnhandledCallbackExceptionError;
use Atk4\Ui\Js\Jquery;
use Atk4\Ui\Js\JsExpression;
use Atk4\Ui\Js\JsExpressionable;
use Atk4\Ui\Js\JsFunction;
use Atk4\Ui\Persistence\Ui as UiPersistence;
use Atk4\Ui\UserAction\ExecutorFactory;
use Nyholm\Psr7\Factory\Psr17Factory;
use Nyholm\Psr7\Response;
use Nyholm\Psr7Server\ServerRequestCreator;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamInterface;
use Psr\Http\Message\UploadedFileInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\HttpFoundation;
class App
{
use AppScopeTrait;
use DiContainerTrait;
use DynamicMethodTrait;
use HookTrait;
private const UNHANDLEABLE_ERROR_LEVELS = \E_ERROR | \E_PARSE | \E_CORE_ERROR | \E_CORE_WARNING | \E_COMPILE_ERROR | \E_COMPILE_WARNING;
private const UNSUPPRESSIBLE_ERROR_LEVELS = \PHP_MAJOR_VERSION >= 8 ? (\E_ERROR | \E_PARSE | \E_CORE_ERROR | \E_COMPILE_ERROR | \E_USER_ERROR | \E_RECOVERABLE_ERROR) : 0;
public const HOOK_BEFORE_EXIT = self::class . '@beforeExit';
public const HOOK_BEFORE_RENDER = self::class . '@beforeRender';
private static ?string $shutdownReservedMemory; // @phpstan-ignore property.onlyRead
private static ?int $errorReportingLevel = null;
/** @var array<string, string> Location where to load JS/CSS files */
public array $cdn = [
'atk' => '/public',
'jquery' => '/public/external/jquery/dist',
'fomantic-ui' => '/public/external/fomantic-ui/dist',
'flatpickr' => '/public/external/flatpickr/dist',
'highlight.js' => '/public/external/@highlightjs/cdn-assets',
'chart.js' => '/public/external/chart.js/dist', // for atk4/chart
];
/** @var ExecutorFactory App wide executor factory object for Model user action. */
protected $executorFactory;
/**
* @var string Version of Agile UI
*
* @TODO remove, no longer needed for CDN versioning as we bundle all resources
*/
public $version = '5.3-dev';
/** @var string Name of application */
public $title = 'Agile UI - Untitled Application';
/** @var Layout the top-most view object */
public $layout;
/** @var string|list<string> Set one or more directories where templates should reside. */
public $templateDir;
/** @var bool Will replace an exception handler with our own, that will output errors nicely. */
public $catchExceptions = true;
/** Will display error if callback wasn't triggered. */
protected bool $catchRunawayCallbacks = true;
/** Will always run application even if developer didn't explicitly executed run();. */
protected bool $alwaysRun = true;
/** Will be set to true after app->run() is called, which may be done automatically on exit. */
public bool $runCalled = false;
/** Will be set to true after exit is called. */
private bool $exitCalled = false;
public bool $isRendering = false;
/** @var UiPersistence */
public $uiPersistence;
/** @var View|null For internal use */
public $html;
/** @var LoggerInterface|null Target for objects with DebugTrait */
public $logger;
/** @var Persistence|Persistence\Sql */
public $db;
/** @var App\SessionManager */
public $session;
private ServerRequestInterface $request;
private ResponseInterface $response;
/**
* If filename path part is missing during building of URL, this page will be used.
* Set to empty string when when your webserver supports index.php autoindex or you use mod_rewrite with routing.
*
* @internal only for self::url() method
*/
protected string $urlBuildingIndexPage = 'index';
/**
* Remove and re-add the extension of the file during parsing requests and building URL.
*
* @internal only for self::url() method
*/
protected string $urlBuildingExt = '.php';
/** @var bool Call exit in place of throw Exception when Application need to exit. */
public $callExit = true;
/** @var array<string, bool> global sticky arguments */
protected array $stickyGetArguments = [
'__atk_json' => false,
];
/** @var class-string */
public $templateClass = HtmlTemplate::class;
/**
* @param array<string, mixed> $defaults
*/
public function __construct(array $defaults = [])
{
if (isset($defaults['request'])) {
$this->request = $defaults['request'];
unset($defaults['request']);
} else {
$requestFactory = new Psr17Factory();
$requestCreator = new ServerRequestCreator($requestFactory, $requestFactory, $requestFactory, $requestFactory);
$noGlobals = [];
foreach (['_GET', '_COOKIE', '_FILES'] as $k) {
if (!array_key_exists($k, $GLOBALS)) {
$noGlobals[] = $k;
$GLOBALS[$k] = [];
}
}
try {
$this->request = $requestCreator->fromGlobals();
} finally {
foreach ($noGlobals as $k) {
unset($GLOBALS[$k]);
}
}
}
if (isset($defaults['response'])) {
$this->response = $defaults['response'];
unset($defaults['response']);
} else {
$this->response = new Response();
}
// disable caching by default
$this->setResponseHeader('Cache-Control', 'no-store');
$this->setApp($this);
$this->setDefaults($defaults);
$this->setupTemplateDirs();
foreach ($this->cdn as $k => $v) {
if (str_starts_with($v, '/') && !str_starts_with($v, '//')) {
$this->cdn[$k] = $this->createRequestPathFromLocalPath(__DIR__ . '/..' . $v);
}
}
// set our exception handler
if ($this->catchExceptions) {
set_exception_handler(\Closure::fromCallable([$this, 'caughtException']));
$createErrorExceptionArgsFx = static function (int $severity, string $msg, string $file, int $line) {
return [$msg, 0, $severity, $file, $line];
};
set_error_handler(function (int $severity, string $msg, string $file, int $line) use ($createErrorExceptionArgsFx): bool {
if ((error_reporting() & ~self::UNSUPPRESSIBLE_ERROR_LEVELS) === 0) {
$isFirstFrame = true;
foreach (array_slice(debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, 10), 1) as $frame) {
// allow to suppress any warning outside Atk4
if ($isFirstFrame) {
$isFirstFrame = false;
if (!isset($frame['class']) || !str_starts_with($frame['class'], 'Atk4\\')) {
return false;
}
}
// allow to suppress undefined property warning
if (isset($frame['class']) && TraitUtil::hasTrait($frame['class'], WarnDynamicPropertyTrait::class)
&& $frame['function'] === 'warnPropertyDoesNotExist') {
return false;
}
}
}
$this->throwOrCaughtExceptionIfInShutdown(new \ErrorException(...$createErrorExceptionArgsFx($severity, $msg, $file, $line)));
});
register_shutdown_function(function () use ($createErrorExceptionArgsFx): void {
$error = error_get_last();
if ($error !== null && ($error['type'] & self::UNHANDLEABLE_ERROR_LEVELS) !== 0) {
$this->throwOrCaughtExceptionIfInShutdown(new \ErrorException(...$createErrorExceptionArgsFx($error['type'], $error['message'], $error['file'], $error['line'])));
}
});
self::$errorReportingLevel = error_reporting();
error_reporting(self::$errorReportingLevel & ~self::UNHANDLEABLE_ERROR_LEVELS);
http_response_code(500);
header('Content-Type: text/plain');
header('Cache-Control: no-store');
}
// always run app on shutdown
if ($this->alwaysRun) {
$this->setupAlwaysRun();
}
if ($this->uiPersistence === null) {
$this->uiPersistence = new UiPersistence();
}
if (!str_starts_with($this->getRequest()->getUri()->getPath(), '/')) {
throw (new Exception('Request URL path must always start with \'/\''))
->addMoreInfo('url', (string) $this->getRequest()->getUri());
}
if ($this->session === null) {
$this->session = new App\SessionManager();
}
// setting up default executor factory
$this->executorFactory = Factory::factory([ExecutorFactory::class]);
}
public function setExecutorFactory(ExecutorFactory $factory): void
{
$this->executorFactory = $factory;
}
public function getExecutorFactory(): ExecutorFactory
{
return $this->executorFactory;
}
protected function setupTemplateDirs(): void
{
if ($this->templateDir === null) {
$this->templateDir = [];
} elseif (!is_array($this->templateDir)) {
$this->templateDir = [$this->templateDir];
}
$this->templateDir[] = dirname(__DIR__) . '/template';
}
/**
* @return ($calledFromShutdownHandler is true ? void : never)
*/
public function callExit(bool $calledFromShutdownHandler = false): void
{
if (!$this->exitCalled) {
$this->exitCalled = true;
$this->hook(self::HOOK_BEFORE_EXIT);
}
if (!$calledFromShutdownHandler) {
if (!$this->callExit) {
// case process is not in shutdown mode
// App as already done everything
// App need to stop output
// set_handler to catch/trap any exception
set_exception_handler(static function (\Throwable $t): void {});
// raise exception to be trapped and stop execution
throw new ExitApplicationError();
}
exit;
}
}
/**
* @return never
*/
private function throwOrCaughtExceptionIfInShutdown(\Throwable $exception): void
{
if (\PHP_VERSION_ID < 80300 && self::$shutdownReservedMemory === null) {
// remove method once PHP 8.2 support is dropped
// https://github.com/php/php-src/issues/10695
$this->caughtException($exception);
exit(1);
}
throw $exception;
}
protected function caughtException(\Throwable $exception): void
{
if ($exception instanceof LateOutputError) {
$this->outputLateOutputError($exception);
}
while ($exception instanceof UnhandledCallbackExceptionError) {
$exception = $exception->getPrevious();
}
$this->catchRunawayCallbacks = false;
// just replace layout to avoid any extended App->_construct problems
// it will maintain everything as in the original app StickyGet, logger, Events
$this->html = null;
$this->initLayout([Layout\Centered::class]);
$this->layout->template->dangerouslySetHtml('Content', $this->renderExceptionHtml($exception));
$this->layout->template->tryDel('Header');
$this->setResponseHeader('Cache-Control', 'no-store');
if (($this->isJsUrlRequest() || $this->getRequest()->getHeaderLine('X-Requested-With') === 'XMLHttpRequest')
&& !$this->hasRequestQueryParam('__atk_tab')) {
$this->outputResponseJson([
'success' => false,
'message' => $this->layout->getHtml(),
]);
} else {
$this->setResponseStatusCode(500);
$this->run();
}
// process is already in shutdown because of uncaught exception
$this->callExit(true);
}
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
/**
* Check if a specific GET parameter exists in the HTTP request.
*/
public function hasRequestQueryParam(string $key): bool
{
return $this->tryGetRequestQueryParam($key) !== null;
}
/**
* Try to get the value of a specific GET parameter from the HTTP request.
*/
public function tryGetRequestQueryParam(string $key): ?string
{
return $this->getRequest()->getQueryParams()[$key] ?? null;
}
/**
* Get the value of a specific GET parameter from the HTTP request.
*/
public function getRequestQueryParam(string $key): string
{
$res = $this->tryGetRequestQueryParam($key);
if ($res === null) {
throw (new Exception('GET param does not exist'))
->addMoreInfo('key', $key);
}
return $res;
}
/**
* Check if a specific POST parameter exists in the HTTP request.
*/
public function hasRequestPostParam(string $key): bool
{
return $this->tryGetRequestPostParam($key) !== null;
}
/**
* Try to get the value of a specific POST parameter from the HTTP request.
*/
public function tryGetRequestPostParam(string $key): ?string
{
return $this->getRequest()->getParsedBody()[$key] ?? null;
}
/**
* Get the value of a specific POST parameter from the HTTP request.
*/
public function getRequestPostParam(string $key): string
{
$res = $this->tryGetRequestPostParam($key);
if ($res === null) {
throw (new Exception('POST param does not exist'))
->addMoreInfo('key', $key);
}
return $res;
}
/**
* Check if a specific uploaded file exists in the HTTP request.
*/
public function hasRequestUploadedFile(string $key): bool
{
return $this->tryGetRequestUploadedFile($key) !== null;
}
/**
* Try to get a specific uploaded file from the HTTP request.
*/
public function tryGetRequestUploadedFile(string $key): ?UploadedFileInterface
{
return $this->getRequest()->getUploadedFiles()[$key] ?? null;
}
/**
* Get a specific uploaded file from the HTTP request.
*/
public function getRequestUploadedFile(string $key): UploadedFileInterface
{
$res = $this->tryGetRequestUploadedFile($key);
if ($res === null) {
throw (new Exception('FILES upload does not exist'))
->addMoreInfo('key', $key);
}
return $res;
}
public function getResponse(): ResponseInterface
{
return $this->response;
}
protected function assertHeadersNotSent(): void
{
if (headers_sent()
&& \PHP_SAPI !== 'cli' // for phpunit
&& $this->response->getHeaderLine('Content-Type') !== 'text/event-stream' // for SSE
) {
$lateError = new LateOutputError('Headers already sent, more headers cannot be set at this stage');
if ($this->catchExceptions) {
$this->caughtException($lateError);
$this->outputLateOutputError($lateError);
}
throw $lateError;
}
}
/**
* @return $this
*/
public function setResponseStatusCode(int $statusCode): self
{
$this->assertHeadersNotSent();
$this->response = $this->response->withStatus($statusCode);
return $this;
}
/**
* @return $this
*/
public function setResponseHeader(string $name, string $value): self
{
$this->assertHeadersNotSent();
if ($value === '') {
$this->response = $this->response->withoutHeader($name);
} else {
$name = preg_replace_callback('~(?<![a-zA-Z])[a-z]~', static function ($matches) {
return strtoupper($matches[0]);
}, strtolower($name));
$this->response = $this->response->withHeader($name, $value);
}
return $this;
}
/**
* Will perform a preemptive output and terminate. Do not use this
* directly, instead call it from Callback, JsCallback or similar
* other classes.
*
* @param string|StreamInterface|array<mixed> $output Array type is supported only for JSON response
*
* @return never
*/
public function terminate($output = ''): void
{
$type = preg_replace('~;.*~', '', strtolower($this->response->getHeaderLine('Content-Type'))); // in LC without charset
if ($type === '') {
throw new Exception('Content type must be always set');
}
if ($output instanceof StreamInterface) {
$this->response = $this->response->withBody($output);
$this->outputResponse('');
} elseif ($type === 'application/json') {
if (is_string($output)) {
$output = $this->decodeJson($output);
}
$this->outputResponseJson($output);
} elseif ($type === 'text/html') {
$this->outputResponseHtml($output);
} else {
$this->outputResponse($output);
}
$this->runCalled = true; // prevent shutdown function from triggering
$this->callExit();
}
/**
* @param string|array<mixed>|View|HtmlTemplate $output
*
* @return never
*/
public function terminateHtml($output): void
{
if ($output instanceof View) {
$output = $output->renderToHtml();
} elseif ($output instanceof HtmlTemplate) {
$output = $output->renderToHtml();
}
$this->setResponseHeader('Content-Type', 'text/html');
$this->terminate($output);
}
/**
* @param string|array<mixed>|View $output
*
* @return never
*/
public function terminateJson($output): void
{
if ($output instanceof View) {
$output = $output->renderToJsonArr();
}
$this->setResponseHeader('Content-Type', 'application/json');
$this->terminate($output);
}
/**
* Initializes layout.
*
* @param Layout|array<mixed> $seed
*
* @return $this
*/
public function initLayout($seed)
{
$layout = Layout::fromSeed($seed);
$layout->setApp($this);
if ($this->html === null) {
$this->html = new View(['defaultTemplate' => 'html.html']);
$this->html->setApp($this);
$this->html->invokeInit();
}
$this->layout = $this->html->add($layout); // @phpstan-ignore assign.propertyType
$this->initIncludes();
return $this;
}
/**
* Initialize JS and CSS includes.
*/
public function initIncludes(): void
{
$minified = !file_exists(__DIR__ . '/../.git');
// jQuery
$this->requireJs($this->cdn['jquery'] . '/jquery' . ($minified ? '.min' : '') . '.js');
// Fomantic-UI
$this->requireJs($this->cdn['fomantic-ui'] . '/semantic' . ($minified ? '.min' : '') . '.js');
$this->requireCss($this->cdn['fomantic-ui'] . '/semantic' . ($minified ? '.min' : '') . '.css');
// flatpickr - TODO should be load only when needed
// needs https://github.com/atk4/ui/issues/1875
$this->requireJs($this->cdn['flatpickr'] . '/flatpickr' . ($minified ? '.min' : '') . '.js');
$this->requireCss($this->cdn['flatpickr'] . '/flatpickr' . ($minified ? '.min' : '') . '.css');
if ($this->uiPersistence->locale !== 'en') {
$this->requireJs($this->cdn['flatpickr'] . '/l10n/' . $this->uiPersistence->locale . '.js');
$this->html->js(true, new JsExpression('flatpickr.localize(window.flatpickr.l10ns.' . $this->uiPersistence->locale . ')'));
}
// Agile UI
$this->requireJs($this->cdn['atk'] . '/js/atkjs-ui' . ($minified ? '.min' : '') . '.js');
$this->requireCss($this->cdn['atk'] . '/css/agileui.min.css');
// set JS bundle dynamic loading path
$this->html->template->dangerouslySetHtml(
'InitJsBundle',
(new JsExpression('window.__atkBundlePublicPath = [];', [$this->cdn['atk']]))->jsRender()
);
}
/**
* Adds a <style> block to the HTML Header. Not escaped. Try to avoid
* and use file include instead.
*
* @param string $style CSS rules, like ".foo { background: red }".
*/
public function addStyle($style): void
{
$this->html->template->dangerouslyAppendHtml('Head', $this->getTag('style', [], $style));
}
/**
* Add a new object into the app. You will need to have Layout first.
*
* @param AbstractView $object
* @param string|array<mixed>|null $region
*
* @return ($object is View ? View : AbstractView)
*/
public function add($object, $region = null): AbstractView
{
if (!$this->layout) { // @phpstan-ignore booleanNot.alwaysFalse
throw (new Exception('App layout is missing'))
->addSolution('$app->initLayout() must be called first');
}
return $this->layout->add($object, $region);
}
/**
* Runs app and echo rendered template.
*/
public function run(): void
{
$isExitException = false;
try {
$this->runCalled = true;
$this->hook(self::HOOK_BEFORE_RENDER);
$this->isRendering = true;
$this->html->template->set('title', $this->title);
$this->html->renderAll();
$this->html->template->dangerouslyAppendHtml(
'Head',
$this->getTag('script', [], (new Jquery(new JsFunction([], $this->html->getJs())))->jsRender() . ';')
);
$this->isRendering = false;
if ($this->hasRequestQueryParam(Callback::URL_QUERY_TARGET) && $this->catchRunawayCallbacks) {
throw (new Exception('Callback requested, but never reached. You may be missing some arguments in request URL.'))
->addMoreInfo('callback', $this->getRequestQueryParam(Callback::URL_QUERY_TARGET));
}
$output = $this->html->template->renderToHtml();
} catch (ExitApplicationError $e) {
$output = '';
$isExitException = true;
}
if (!$this->exitCalled) { // output already sent by terminate()
if ($this->isJsUrlRequest()) {
$this->outputResponseJson($output);
} else {
$this->outputResponseHtml($output);
}
}
if ($isExitException) {
$this->callExit();
}
}
/**
* Load template by template file name.
*
* @return HtmlTemplate
*/
public function loadTemplate(string $filename)
{
$template = new $this->templateClass();
if ((['.' => true, '/' => true, '\\' => true][substr($filename, 0, 1)] ?? false) || str_contains($filename, ':\\')) {
return $template->loadFromFile($filename);
}
$dirs = is_array($this->templateDir) ? $this->templateDir : [$this->templateDir];
foreach ($dirs as $dir) {
$t = $template->tryLoadFromFile($dir . '/' . $filename);
if ($t !== false) {
return $t;
}
}
throw (new Exception('Cannot find template file'))
->addMoreInfo('filename', $filename)
->addMoreInfo('templateDir', $this->templateDir);
}
protected function createRequestPathFromLocalPath(string $localPath): string
{
// $localPath does not need realpath() as the path is expected to be built using __DIR__
// which has symlinks resolved
static $requestUrlPath = null;
static $requestLocalPath = null;
if ($requestUrlPath === null) {
if (\PHP_SAPI === 'cli') { // for phpunit
$requestUrlPath = '/';
$requestLocalPath = \Closure::bind(static function () {
return (new ExceptionRenderer\Html(new \Exception()))->getVendorDirectory();
}, null, ExceptionRenderer\Html::class)();
} else {
$request = new HttpFoundation\Request([], [], [], [], [], $_SERVER);
$requestUrlPath = $request->getBasePath();
$requestLocalPath = realpath($request->server->get('SCRIPT_FILENAME'));
}
}
$fs = new Filesystem();
$localPathRelative = $fs->makePathRelative($localPath, dirname($requestLocalPath));
$res = '/' . $fs->makePathRelative($requestUrlPath . '/' . $localPathRelative, '/');
// fix https://github.com/symfony/symfony/pull/40051
if (str_ends_with($res, '/') && !str_ends_with($localPath, '/')) {
$res = substr($res, 0, -1);
}
return $res;
}
/**
* Make current get argument with specified name automatically appended to all generated URLs.
*/
public function stickyGet(string $name, bool $isDeleting = false): ?string
{
$this->stickyGetArguments[$name] = !$isDeleting;
return $this->tryGetRequestQueryParam($name);
}
/**
* Remove sticky GET which was set by stickyGet.
*/
public function stickyForget(string $name): void
{
unset($this->stickyGetArguments[$name]);
}
/**
* Build a URL that application can use for loading HTML data.
*
* @param string|array<0|string, string|int|false> $page URL as string or array with page path as first element and other GET arguments
* @param array<string, string> $extraRequestUrlArgs additional URL arguments, deleting sticky can delete them
*/
public function url($page = [], array $extraRequestUrlArgs = []): string
{
if (is_string($page)) {
$pageExploded = explode('?', $page, 2);
parse_str($pageExploded[1] ?? '', $page);
$pagePath = $pageExploded[0] !== '' ? $pageExploded[0] : null;
} else {
$pagePath = $page[0] ?? null;
unset($page[0]);
}
$request = $this->getRequest();
if ($pagePath === null) {
$pagePath = $request->getUri()->getPath();
}
if (str_ends_with($pagePath, '/')) {
$pagePath .= $this->urlBuildingIndexPage;
}
if (!str_ends_with($pagePath, '/') && !str_contains(basename($pagePath), '.')) {
$pagePath .= $this->urlBuildingExt;
}
$args = $extraRequestUrlArgs;
// add sticky arguments
$requestQueryParams = $request->getQueryParams();
foreach ($this->stickyGetArguments as $k => $v) {
if ($v && isset($requestQueryParams[$k])) {
$args[$k] = $requestQueryParams[$k];
} else {
unset($args[$k]);
}
}
// add arguments
foreach ($page as $k => $v) {
if ($v === false) {
unset($args[$k]);
} else {
$args[$k] = $v;
}
}
$pageQuery = http_build_query($args, '', '&', \PHP_QUERY_RFC3986);
return $pagePath . ($pageQuery !== '' ? '?' . $pageQuery : '');
}
/**
* Build a URL that application can use for JS callbacks. Some framework integration will use a different routing
* mechanism for non-HTML response.
*
* @param string|array<0|string, string|int|false> $page URL as string or array with page path as first element and other GET arguments
* @param array<string, string> $extraRequestUrlArgs additional URL arguments, deleting sticky can delete them
*/
public function jsUrl($page = [], array $extraRequestUrlArgs = []): string
{
// append to the end but allow override
$extraRequestUrlArgs = array_merge($extraRequestUrlArgs, ['__atk_json' => 1], $extraRequestUrlArgs);
return $this->url($page, $extraRequestUrlArgs);
}
/**
* Request was made using App::jsUrl().
*/
public function isJsUrlRequest(): bool
{
return $this->hasRequestQueryParam('__atk_json') && $this->getRequestQueryParam('__atk_json') !== '0';
}
/**
* Adds additional JS script include in application template.
*
* @param string $url
* @param bool $isAsync whether or not you want Async loading
* @param bool $isDefer whether or not you want Defer loading
*
* @return $this
*/
public function requireJs($url, $isAsync = false, $isDefer = false)
{
$this->html->template->dangerouslyAppendHtml('Head', $this->getTag('script', ['src' => $url, 'defer' => $isDefer, 'async' => $isAsync], '') . "\n");
return $this;
}
/**
* Adds additional CSS stylesheet include in application template.
*
* @param string $url
*
* @return $this
*/
public function requireCss($url)
{
$this->html->template->dangerouslyAppendHtml('Head', $this->getTag('link/', ['rel' => 'stylesheet', 'type' => 'text/css', 'href' => $url]) . "\n");
return $this;
}
/**
* A convenient wrapper for sending user to another page.
*
* @param string|array<0|string, string|int|false> $page
*/
public function redirect($page, bool $permanent = false): void
{
$this->setResponseStatusCode($permanent ? 301 : 302);
$this->setResponseHeader('location', $this->url($page));
$this->terminateHtml('');
}
/**
* Generate action for redirecting user to another page.
*
* @param string|array<0|string, string|int|false> $page
*/
public function jsRedirect($page, bool $newWindow = false): JsExpressionable
{
return new JsExpression('window.open([], [])', [$this->url($page), $newWindow ? '_blank' : '_top']);
}
public function isVoidTag(string $tag): bool
{
return [
'area' => true, 'base' => true, 'br' => true, 'col' => true, 'embed' => true,
'hr' => true, 'img' => true, 'input' => true, 'link' => true, 'meta' => true,
'param' => true, 'source' => true, 'track' => true, 'wbr' => true,
][strtolower($tag)] ?? false;
}
/**
* Construct HTML tag with supplied attributes.
*
* $html = getTag('img/', ['src' => 'foo.gif', 'border' => 0])
* --> "<img src="foo.gif" border="0">"
*
*
* The following rules are respected:
*
* 1. all array key => val elements appear as attributes with value escaped.
* getTag('input/', ['value' => 'he"llo'])
* --> <input value="he"llo">
*
* 2. true value will add attribute without value
* getTag('td', ['nowrap' => true])
* --> <td nowrap="nowrap">
*
* 3. false value will ignore the attribute
* getTag('img/', ['src' => false])
* --> <img>
*
* 4. passing key 0 => "val" will re-define the element itself
* getTag('div', ['a', 'href' => 'picture'])
* --> <a href="picture">
*
* 5. use '/' at end of tag to self-close it (self closing slash is not rendered because of HTML5 void tag)
* getTag('img/', ['src' => 'foo.gif'])
* --> <img src="foo.gif">
*
* 6. if main tag is self-closing, overriding it keeps it self-closing
* getTag('img/', ['input', 'type' => 'picture'])
* --> <input type="picture">
*
* 7. simple way to close tag. Any attributes to closing tags are ignored
* getTag('/td')
* --> </td>
*
* 7b. except for 0 => 'newtag'
* getTag('/td', ['th', 'align' => 'left'])
* --> </th>
*
* 8. using $value will add value inside tag. It will also encode value.
* getTag('a', ['href' => 'foo.html'], 'click here >>')
* --> <a href="foo.html">click here >></a>
*
* 9. pass array as 3rd parameter to nest tags. Each element can be either string (inserted as-is) or
* array (passed to getTag recursively)
* getTag('a', ['href' => 'foo.html'], [['b', 'click here'], ' for fun'])
* --> <a href="foo.html"><b>click here</b> for fun</a>
*
* 10. extended example:
* getTag('a', ['href' => 'hello'], [
* ['b', 'class' => 'red', [
* ['i', 'class' => 'blue', 'welcome']
* ]]
* ])
* --> <a href="hello"><b class="red"><i class="blue">welcome</i></b></a>'
*
* @param array<0|string, string|bool> $attr
* @param string|list<array{0: string, 1?: array<0|string, string|bool>, 2?: string|list<mixed>|null}|string>|null $value
*/
public function getTag(string $tag, array $attr = [], $value = null): string
{
$tag = strtolower($tag);
$tagOrig = $tag;
$isOpening = true;
$isClosing = false;
if (substr($tag, 0, 1) === '/') {
$tag = substr($tag, 1);
$isOpening = false;
$isClosing = true;
} elseif (substr($tag, -1) === '/') {
$tag = substr($tag, 0, -1);
$isClosing = true;
}
$isVoid = $this->isVoidTag($tag);
if ($isVoid
? $isOpening && !$isClosing || !$isOpening || $value !== null
: $isOpening && $isClosing
) {
throw (new Exception('Wrong void tag usage'))
->addMoreInfo('tag', $tagOrig)
->addMoreInfo('isVoid', $isVoid);
}
if (isset($attr[0])) {
if ($isClosing) {
if ($isOpening) {
$tag = $attr[0] . '/';
} else {
$tag = '/' . $attr[0];
}
} else {
$tag = $attr[0];
}
unset($attr[0]);
return $this->getTag($tag, $attr, $value);
}
if ($value !== null) {
$result = [];
foreach (is_scalar($value) ? [$value] : $value as $v) {
if (is_array($v)) {
$result[] = $this->getTag(...$v);
} elseif (['script' => true, 'style' => true][$tag] ?? false) {
if ($tag === 'script' && $v !== '') {
$result[] = '\'use strict\'; ';
}
// see https://mathiasbynens.be/notes/etago
$result[] = preg_replace('~(?<=<)(?=/\s*' . preg_quote($tag, '~') . '|!--)~', '\\\\', $v);
} elseif (is_array($value)) { // todo, remove later and fix wrong usages, this is the original behaviour, only directly passed strings were escaped
$result[] = $v;
} else {
$result[] = $this->encodeHtml($v);
}
}
$value = implode('', $result);
}
$tmp = [];
foreach ($attr as $key => $val) {
if ($val === false) {
continue;
}
if ($val === true) {
$val = $key;
}
$val = (string) $val;
$tmp[] = $key . '="' . $this->encodeHtml($val) . '"';
}
if ($isClosing && !$isOpening) {
return '</' . $tag . '>';
}
return '<' . $tag . ($tmp !== [] ? ' ' . implode(' ', $tmp) : '') . ($isClosing && !$isVoid ? ' /' : '') . '>'
. ($value !== null ? $value . '</' . $tag . '>' : '');
}
/**
* Encodes string - convert all applicable chars to HTML entities.
*/
public function encodeHtml(string $value): string
{
return htmlspecialchars($value, \ENT_HTML5 | \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8');
}
/**
* @return mixed
*/
public function decodeJson(string $json)
{
$data = json_decode($json, true, 512, \JSON_BIGINT_AS_STRING | \JSON_THROW_ON_ERROR);
return $data;
}
/**
* @param mixed $data
*/
public function encodeJson($data, bool $forceObject = false): string
{
if (is_array($data) || is_object($data)) {
$checkNoObjectFx = static function ($v) {
if (is_object($v)) {
throw (new Exception('Object to JSON encode is not supported'))
->addMoreInfo('value', $v);
}
};
if (is_object($data)) {
$checkNoObjectFx($data);
} else {
array_walk_recursive($data, $checkNoObjectFx);
}
}
$options = \JSON_UNESCAPED_SLASHES | \JSON_PRESERVE_ZERO_FRACTION | \JSON_UNESCAPED_UNICODE | \JSON_PRETTY_PRINT;
if ($forceObject) {
$options |= \JSON_FORCE_OBJECT;
}
$json = json_encode($data, $options | \JSON_THROW_ON_ERROR, 512);
// IMPORTANT: always convert large integers to string, otherwise numbers can be rounded by JS
// replace large JSON integers only, do not replace anything in JSON/JS strings
$json = preg_replace_callback('~"(?:[^"\\\]+|\\\.)*+"\K|\'(?:[^\'\\\]+|\\\.)*+\'\K'
. '|(?:^|[{\[,:])[ \n\r\t]*\K-?[1-9]\d{15,}(?=[ \n\r\t]*(?:$|[}\],:]))~s', static function ($matches) {
if ($matches[0] === '' || abs((int) $matches[0]) < (2 ** 53)) {
return $matches[0];
}
return '"' . $matches[0] . '"';
}, $json);
return $json;
}
/**
* Return exception message using HTML block and Fomantic-UI formatting. It's your job
* to put it inside boilerplate HTML and output, e.g:.
*
* $app = new App();
* $app->initLayout([Layout\Centered::class]);
* $app->layout->template->dangerouslySetHtml('Content', $e->getHtml());
* $app->run();
* $app->callExit();
*/
public function renderExceptionHtml(\Throwable $exception): string
{
return (string) new ExceptionRenderer\Html($exception);
}
protected function setupAlwaysRun(): void
{
register_shutdown_function(function () {
if (!$this->runCalled) {
try {
$this->run();
$this->callExit(true);
} catch (ExitApplicationError $e) {
// continue shutdown
} catch (\Throwable $e) {
// set_exception_handler does not work in shutdown
// https://github.com/php/php-src/issues/10695
$this->caughtException($e);
}
}
});
}
/**
* @internal should be called only from self::outputResponse() and self::outputLateOutputError()
*/
protected function emitResponse(): void
{
if (http_response_code() !== 500 || $this->response->getStatusCode() !== 500 || $this->response->getHeaders() !== []) { // avoid throwing late error in loop
http_response_code($this->response->getStatusCode());
header_remove('Content-Type');
header_remove('Cache-Control');
}
foreach ($this->response->getHeaders() as $name => $values) {
foreach ($values as $value) {
header($name . ': ' . $value, false);
}
}
$stream = $this->response->getBody();
if ($stream->isSeekable()) {
$stream->rewind();
}
// for streaming response
if (!$stream->isReadable()) {
return;
}
while (!$stream->eof()) {
echo $stream->read(16 * 1024);
}
}
protected function outputResponse(string $data): void
{
foreach (ob_get_status(true) as $status) {
if ($status['buffer_used'] !== 0) {
$lateError = new LateOutputError('Unexpected output detected');
if ($this->catchExceptions) {
$this->caughtException($lateError);
$this->outputLateOutputError($lateError);
}
throw $lateError;
}
}
$this->assertHeadersNotSent();
// TODO hack for SSE
// https://github.com/atk4/ui/pull/1706#discussion_r757819527
if (headers_sent() && $this->response->getHeaderLine('Content-Type') === 'text/event-stream') {
echo $data;
return;
}
if ($data !== '') {
$this->response->getBody()->write($data);
}
$this->emitResponse();
}
/**
* @return never
*/
protected function outputLateOutputError(LateOutputError $exception): void
{
$this->response = $this->response->withStatus(500);
// late error means headers were already sent to the client, so remove all response headers,
// to avoid throwing late error in loop
foreach (array_keys($this->response->getHeaders()) as $name) {
$this->response = $this->response->withoutHeader($name);
}
$this->response = $this->response->withBody((new Psr17Factory())->createStream("\n"
. '!! FATAL UI ERROR: ' . $exception->getMessage() . ' !!'
. "\n"));
$this->emitResponse();
$this->runCalled = true; // prevent shutdown function from triggering
exit(1); // should be never reached from phpunit because we set catchExceptions = false
}
/**
* Output HTML response to the client.
*/
private function outputResponseHtml(string $data): void
{
$this->setResponseHeader('Content-Type', 'text/html');
$this->outputResponse($data);
}
/**
* @param string|array<mixed> $data
*/
private function outputResponseJson($data): void
{
if (!is_string($data)) {
$data = $this->encodeJson($data);
}
$this->setResponseHeader('Content-Type', 'application/json');
$this->outputResponse($data);
}
}
\Closure::bind(static function (): void {
// reserve 1 MB of memory for shutdown
App::$shutdownReservedMemory = str_repeat(str_repeat('shutdownReserved', 256), 256);
register_shutdown_function(static function (): void {
App::$shutdownReservedMemory = null;
if (App::$errorReportingLevel !== null) {
error_reporting(error_reporting() | (App::$errorReportingLevel & App::UNHANDLEABLE_ERROR_LEVELS));
}
});
}, null, App::class)();