diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 0000000..8c2c7a4 --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,39 @@ +name: PHP Composer + +on: + push: + branches: [ "main", "refactors" ] + pull_request: + branches: [ "main", "refactors" ] + +permissions: + contents: read + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Validate composer.json and composer.lock + run: composer validate --strict + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Start PHP Development Server + run: php -S localhost:61001 -t public & + + - name: Run ci suite + run: composer run-script ci diff --git a/.gitignore b/.gitignore index c6ae115..5252a63 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,9 @@ app/cache/ .DS_Store *.sublime-* biome.* +.phpunit.result.cache +node_modules/ +tests/cache/ +.coverage +.runway-config.json +apm.db* diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..4b88736 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +composer validate --no-ansi +composer ci diff --git a/.runway-config-sample.json b/.runway-config-sample.json new file mode 100644 index 0000000..1988a6f --- /dev/null +++ b/.runway-config-sample.json @@ -0,0 +1,6 @@ +{ + "apm": { + "source_type": "sqlite", + "dest_db_dsn": "sqlite::memory:" + } +} diff --git a/app/config/bootstrap.php b/app/config/bootstrap.php index 4f3a78d..6911851 100644 --- a/app/config/bootstrap.php +++ b/app/config/bootstrap.php @@ -6,30 +6,20 @@ * for every request made to the application. */ -$ds = DIRECTORY_SEPARATOR; -require_once __DIR__ . $ds . '..' . $ds . '..' . $ds . 'vendor' . $ds . 'autoload.php'; +use app\utils\Config; -if (file_exists(__DIR__ . $ds . 'config.php') === false) { +require_once __DIR__ . '/../../vendor/autoload.php'; + +if (!file_exists(__DIR__ . '/config.php')) { Flight::halt(500, 'Config file not found. Please create a config.php file in the app/config directory to get started.'); } -// this has to be hard code required because autoload hasn't been registered yet. -require_once __DIR__ . $ds . '..' . $ds . 'utils' . $ds . 'CustomFlight.php'; - -// It is better practice to not use static methods for everything. It makes your -// app much more difficult to unit test easily. -$app = Flight::app(); - /* * Load the config file * P.S. When you require a php file and that file returns an array, the array * will be returned by the require statement where you can assign it to a var. */ -$config = require __DIR__ . $ds . 'config.php'; -$app->set('config', $config); - -// Whip out the ol' router and we'll pass that to the routes file -$router = $app->router(); +Flight::set('config', new Config(require __DIR__ . '/config.php')); /* * Load the routes file. the $router variable above is passed into the routes.php @@ -38,7 +28,7 @@ * When someone hits that URL, you point them to a function or method * that will handle the request. */ -require_once __DIR__ . $ds . 'routes.php'; +require_once __DIR__ . '/routes.php'; /* * You additionally could just define the routes in this file. It's up to you. @@ -63,11 +53,12 @@ * That's a discussion for another day. Suffice to say, that Flight has a basic concept * of a services container by registering classes to the Engine class. */ -require_once __DIR__ . $ds . 'services.php'; +require_once __DIR__ . '/services.php'; // At this point, your app should have all the instructions it needs and it'll // "start" processing everything. This is where the magic happens. -$app->start(); +Flight::start(); + /* .----..---. .--. .----. .---. .---. .-. .-. .--. .---. .----. .-. .-..----. .----..-. .-. { {__ {_ _}/ {} \ | {} }{_ _} {_ _}| {_} | / {} \{_ _} | {} }| { } || {} }| {} }\ \/ / diff --git a/app/config/config_sample.php b/app/config/config_sample.php index bed8e7d..a4d9eb5 100644 --- a/app/config/config_sample.php +++ b/app/config/config_sample.php @@ -1,5 +1,6 @@ path(__DIR__ . $ds . '..' . $ds . '..'); -$app->set('flight.base_url', '/'); // if this is in a subdirectory, you'll need to change this -$app->set('flight.case_sensitive', false); // if you want case sensitive routes, set this to true -$app->set('flight.log_errors', true); // if you want to log errors, set this to true -$app->set('flight.handle_errors', false); // if you want flight to handle errors, set this to true -$app->set('flight.views.path', __DIR__ . $ds . '..' . $ds . 'views'); // set the path to your view/template/ui files -$app->set('flight.views.extension', '.php'); // set the file extension for your view/template/ui files -$app->set('flight.content_length', true); // if flight should send a content length header +Flight::set('flight.handle_errors', false); // if you want flight to handle errors, set this to true +Flight::set('flight.views.path', __DIR__ . '/../views'); // set the path to your view/template/ui files /* * Get Tracy up and running @@ -44,15 +28,16 @@ * https://tracy.nette.org/ */ Debugger::enable(); // auto tries to figure out your environment -// Debugger::enable(Debugger::DEVELOPMENT) // sometimes you have to be explicit (also Debugger::PRODUCTION) +// Debugger::enable(Debugger::Development); // sometimes you have to be explicit (also Debugger::Production) // Debugger::enable('23.75.345.200'); // you can also provide an array of IP addresses -Debugger::$logDirectory = __DIR__ . $ds . '..' . $ds . 'log'; +Debugger::$logDirectory = __DIR__ . '/../log'; Debugger::$strictMode = true; // display all errors // Debugger::$strictMode = E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED; // all errors except deprecated notices +// if Debugger bar is visible, then content-length can not be set by Flight if (Debugger::$showBar) { - $app->set('flight.content_length', false); // if Debugger bar is visible, then content-length can not be set by Flight - new TracyExtensionLoader($app); + Flight::set('flight.content_length', false); + (new Container)->get(TracyExtensionLoader::class); } /* diff --git a/app/config/routes.php b/app/config/routes.php index 368be70..255bcaf 100644 --- a/app/config/routes.php +++ b/app/config/routes.php @@ -4,101 +4,175 @@ use app\middleware\HeaderSecurityMiddleware; use app\utils\DocsLogic; use app\utils\Translator; -use app\utils\CustomEngine; -use flight\net\Router; +use flight\Container; -/** @var CustomEngine $app */ -/** @var Router $router */ +Flight::route('GET /api/status', static fn() => Flight::json(['status' => 'ok'])); // This acts like a global middleware -$router->group('', function (Router $router) use ($app) { - - /* - * Specific routes - */ - // This processes github webhooks - $router->post('/update-stuff', [DocsController::class, 'updateStuffPost'], false, 'update_stuff'); - - /* - * Redirects - */ - // if theres no language or version in the url, redirect and default to en and v3 - $app->route('/', function () use ($app) { - // pull out the default language by the accept header - $language = Translator::getLanguageFromRequest(); - $app->redirect('/'.$language.'/v3/'); - }); - - // If the route only defines a language (ex: /en) redirect with a version - $app->route('/@language:[a-z0-9]{2}', function (string $language) use ($app): void { - // if there's a number in it, it's actually probably the version so we'll need to pull the language out and consider this a version - if (preg_match('/\d/', $language) === 1) { - $version = $language; - $language = Translator::getLanguageFromRequest(); - $app->redirect("/en/$language/"); - } else { - $version = 'v3'; - } - $app->redirect("/$language/$version/"); - }); - - // Pick up old routes that didn't use to have a language and version header - $app->route('/@section:[\w\-]{3,}(/@sub_section:[\w\-]{3,})', function (string $section, ?string $sub_section = '') use ($app): void { - $language = Translator::getLanguageFromRequest(); - $app->redirect("/{$language}/v3/$section/$sub_section"); - }); - - /* - * Core routes - */ - $app->group('/@language:[a-z]{2}/@version:[a-z0-9]{2}', function (Router $router) use ($app): void { - $router->get('/', [DocsController::class, 'aboutGet'], false, 'about'); - $router->get('/single-page', [DocsController::class, 'singlePageGet'], false, 'single_page'); - $router->get('/about', [DocsController::class, 'aboutGet']); - $router->get('/install', [DocsController::class, 'installGet'], false, 'install'); - - // Unique URL workaround because this is the only 'single page' with a scrollspy for the time being. - $router->get('/install/install', function () use ($app): void { - $app->redirect($app->getUrl('install')); - }); - - $router->get('/license', [DocsController::class, 'licenseGet'], false, 'license'); - $router->get('/examples', [DocsController::class, 'examplesGet'], false, 'examples'); - $router->get('/media', [DocsController::class, 'mediaGet'], false, 'media'); - $router->get('/search', [DocsController::class, 'searchGet'], false, 'search'); - - $router->group('/learn', function (Router $router): void { - $router->get('', [DocsController::class, 'learnGet'], false, 'learn'); - $router->get('/@section_name', [DocsController::class, 'learnSectionsGet']); - }); - - $router->group('/guides', function (Router $router): void { - $router->get('', [DocsController::class, 'guidesGet'], false, 'guides'); - $router->get('/@section_name', [DocsController::class, 'guidesSectionsGet']); - }); - - $router->group('/awesome-plugins', function (Router $router): void { - $router->get('', [DocsController::class, 'awesomePluginsGet'], false, 'awesome_plugins'); - $router->get('/@plugin_name', [DocsController::class, 'pluginGet'], false, 'plugin'); - }); - }); -}, [ new HeaderSecurityMiddleware() ]); +Flight::group('', static function (): void { + /* + * Specific routes + */ + // This processes github webhooks + Flight::route( + pattern: 'POST /update-stuff', + callback: [DocsController::class, 'updateStuffPost'], + alias: 'update_stuff' + ); + + /* + * Redirects + */ + // if theres no language or version in the url, redirect and default to en + // and v3 + Flight::route('/', static function (): void { + // pull out the default language by the accept header + $language = Translator::getLanguageFromRequest(); + + Flight::redirect("/$language/v3/"); + }); + + // If the route only defines a language (ex: /en) redirect with a version + Flight::route( + '/@language:[a-z0-9]{2}', + static function (string $language): void { + // if there's a number in it, it's actually probably the version so + // we'll need to pull the language out and consider this a version + $version = preg_match('/\d/', $language) ? $language : 'v3'; + + if (preg_match('/\d/', $language)) { + $language = Translator::getLanguageFromRequest(); + Flight::redirect("/en/$language/"); + } + + Flight::redirect("/$language/$version/"); + } + ); + + // Pick up old routes that didn't use to have a language and version header + Flight::route( + '/@section:[\w\-]{3,}(/@sub_section:[\w\-]{3,})', + static function (string $section, ?string $sub_section = ''): void { + $language = Translator::getLanguageFromRequest(); + + Flight::redirect("/$language/v3/$section/$sub_section/"); + } + ); + + /* + * Core routes + */ + Flight::group( + '/@language:[a-z]{2}/@version:[a-z0-9]{2}', + static function (): void { + Flight::route( + pattern: 'GET /', + callback: [DocsController::class, 'aboutGet'], + alias: 'about' + ); + + Flight::route( + pattern: 'GET /single-page', + callback: [DocsController::class, 'singlePageGet'], + alias: 'single_page' + ); + + Flight::route('GET /about', [DocsController::class, 'aboutGet']); + + Flight::route( + pattern: 'GET /install', + callback: [DocsController::class, 'installGet'], + alias: 'install' + ); + + // Unique URL workaround because this is the only 'single page' + // with a scrollspy for the time being. + Flight::route('GET /install/install', static function (): void { + Flight::redirect(Flight::getUrl('install')); + }); + + Flight::route( + pattern: 'GET /license', + callback: [DocsController::class, 'licenseGet'], + alias: 'license' + ); + + Flight::route( + pattern: 'GET /examples', + callback: [DocsController::class, 'examplesGet'], + alias: 'examples' + ); + + Flight::route( + pattern: 'GET /media', + callback: [DocsController::class, 'mediaGet'], + alias: 'media' + ); + + Flight::route( + pattern: 'GET /search', + callback: [DocsController::class, 'searchGet'], + alias: 'search' + ); + + Flight::group('/learn', static function (): void { + Flight::route( + pattern: 'GET ', + callback: [DocsController::class, 'learnGet'], + alias: 'learn' + ); + + Flight::route( + 'GET /@section_name', + [DocsController::class, 'learnSectionsGet'] + ); + }); + + Flight::group('/guides', static function (): void { + Flight::route( + pattern: 'GET /', + callback: [DocsController::class, 'guidesGet'], + alias: 'guides' + ); + + Flight::route( + 'GET /@section_name', + [DocsController::class, 'guidesSectionsGet'] + ); + }); + + Flight::group('/awesome-plugins', static function (): void { + Flight::route( + pattern: 'GET /', + callback: [DocsController::class, 'awesomePluginsGet'], + alias: 'awesome_plugins' + ); + + Flight::route( + pattern: 'GET /@plugin_name', + callback: [DocsController::class, 'pluginGet'], + alias: 'plugin' + ); + }); + } + ); +}, [HeaderSecurityMiddleware::class]); /* * 404 Handler */ -$app->map('notFound', function () use ($app): void { - // Clear out anything that may have been generated - $app->response()->clearBody()->status(404); - - // pull the version out of the URL - $url = $app->request()->url; - $version = preg_match('~/(v\d)/~', $url, $matches) === 1 ? $matches[1] : 'v3'; - - (new DocsLogic($app))->renderPage('not_found.latte', [ - 'title' => '404 Not Found', - 'version' => $version - ]); - $app->response()->send(); - exit; +Flight::map('notFound', static function (): void { + // Clear out anything that may have been generated + Flight::response()->clearBody()->status(404); + + // pull the version out of the URL + $url = Flight::request()->url; + $version = preg_match('~/(v\d)/~', $url, $matches) ? $matches[1] : 'v3'; + + (new Container)->get(DocsLogic::class)->renderPage('not_found.latte', [ + 'title' => '404 Not Found', + 'version' => $version + ]); + + Flight::response()->send(); }); diff --git a/app/config/services.php b/app/config/services.php index 3e2bc9a..5874376 100644 --- a/app/config/services.php +++ b/app/config/services.php @@ -1,25 +1,21 @@ register('translator', Translator::class); +// This translates some common parts of the page, not the content +Flight::register('translator', Translator::class); // Templating Engine used to render the views -$app->register('latte', LatteEngine::class, [], function (LatteEngine $latte) use ($app): void { +Flight::register('latte', LatteEngine::class, [], static function (LatteEngine $latte): void { $latte->setTempDirectory(__DIR__ . '/../cache/'); $latte->setLoader(new FileLoader(__DIR__ . '/../views/')); - $translator = $app->translator(); + $translator = Flight::translator(); $translatorExtension = new TranslatorExtension( [$translator, 'translate'], @@ -29,9 +25,14 @@ }); // Cache for storing parsedown and other things -$app->register('cache', Cache::class, [__DIR__ . '/../cache/'], function (Cache $cache) { +Flight::register('cache', Cache::class, [__DIR__ . '/../cache/'], static function (Cache $cache): void { $cache->setDevMode(ENVIRONMENT === 'development'); }); // Parsedown is a markdown parser -$app->register('parsedown', Parsedown::class); +Flight::register('parsedown', Parsedown::class); + +// Register the APM +$ApmLogger = LoggerFactory::create(__DIR__ . '/../../.runway-config.json'); +$Apm = new Apm($ApmLogger); +$Apm->bindEventsToFlightInstance(Flight::app()); diff --git a/app/controllers/DocsController.php b/app/controllers/DocsController.php index 83355f2..19fe8e5 100644 --- a/app/controllers/DocsController.php +++ b/app/controllers/DocsController.php @@ -6,6 +6,7 @@ use app\utils\DocsLogic; use app\utils\Text; use Exception; +use flight\core\EventDispatcher; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; @@ -155,6 +156,16 @@ public function pluginGet(string $language, string $version, string $plugin_name public function singlePageGet(string $language, string $version) { $app = $this->app; + // Check if the language is valid + if ($this->DocsLogic->checkValidLanguage($language) === false) { + $language = 'en'; + } + + // Check if the version is valid + if ($this->DocsLogic->checkValidVersion($version) === false) { + $version = 'v3'; + } + // recursively look through all the content files, and pull out each section and render it $sections = []; $language_directory = self::CONTENT_DIR . '/' . $version . '/' . $language . '/'; @@ -171,17 +182,23 @@ public function singlePageGet(string $language, string $version) { $Translator = $this->DocsLogic->setupTranslatorService($language, $version); - $markdown_html = $app->cache()->refreshIfExpired('single_page_html_' . $language . '_' . $version, function () use ($app, $sections, $Translator) { - $markdown_html = ''; - - foreach ($sections as $section) { + $cacheHit = true; + $cacheStartTime = microtime(true); + $cacheKey = 'single_page_html_' . $language . '_' . $version; + $markdown_html = $app->cache()->retrieve($cacheKey); + if ($markdown_html === null) { + $cacheHit = false; + $markdown_html = ''; + foreach ($sections as $section) { $slugged_section = Text::slugify($section); $markdown_html .= '

' . ucwords($section) . '

'; $markdown_html .= $app->parsedown()->text($Translator->getMarkdownLanguageFile($section . '.md')); } - return $markdown_html; - }, 86400); // 1 day + $app->cache()->store($cacheKey, $markdown_html, 86400); // 1 day + } + + $app->eventDispatcher()->trigger('flight.cache.checked', 'single_page_get_'.$cacheKey, $cacheHit, microtime(true) - $cacheStartTime); $this->DocsLogic->renderPage('single_page.latte', [ 'page_title' => 'single_page_documentation', diff --git a/app/utils/Config.php b/app/utils/Config.php new file mode 100644 index 0000000..da15d60 --- /dev/null +++ b/app/utils/Config.php @@ -0,0 +1,10 @@ +app->request(); $uri = $request->url; @@ -37,75 +47,109 @@ public function renderPage(string $latte_file, array $params = []) { $this->app->latte()->render($latte_file, $params); } - /** - * Sets up the translator service with the specified language and version. - * - * @param string $language The language to be used by the translator. - * @param string $version The version of the translation service. - * @return Translator The configured translator service. - */ - public function setupTranslatorService(string $language, string $version): Translator { - $Translator = $this->app->translator(); - $Translator->setLanguage($language); - $Translator->setVersion($version); + /** + * Sets up the translator service with the specified language and version. + * + * @param string $language The language to be used by the translator. + * @param string $version The version of the translation service. + * @return Translator The configured translator service. + */ + public function setupTranslatorService(string $language, string $version): Translator { + $Translator = $this->app->translator(); + $Translator->setLanguage($language); + $Translator->setVersion($version); return $Translator; - } - - /** - * Compiles a single page based on the specified language, version, and section. - * - * @param string $language The language of the page to compile. - * @param string $version The version of the page to compile. - * @param string $section The section of the page to compile. - * - * @return void - */ + } + + /** + * Compiles a single page based on the specified language, version, and section. + * + * @param string $language The language of the page to compile. + * @param string $version The version of the page to compile. + * @param string $section The section of the page to compile. + * + * @return void + */ public function compileSinglePage(string $language, string $version, string $section) { $app = $this->app; - $Translator = $this->setupTranslatorService($language, $version); + // Check if the language is valid + if ($this->checkValidLanguage($language) === false) { + $language = 'en'; + } + + // Check if the version is valid + if ($this->checkValidVersion($version) === false) { + $version = 'v3'; + } + + $Translator = $this->setupTranslatorService($language, $version); + + $cacheStartTime = microtime(true); + $cacheHit = true; + $cacheKey = $section . '_html_' . $language . '_' . $version; + $markdown_html = $app->cache()->retrieve($cacheKey); + if ($markdown_html === null) { + $cacheHit = false; + $markdown_html = $app->parsedown()->text($Translator->getMarkdownLanguageFile($section . '.md')); + $markdown_html = Text::addClassesToElements($markdown_html); + $app->cache()->store($cacheKey, $markdown_html, 86400); // 1 day + } - $markdown_html = $app->cache()->refreshIfExpired($section . '_html_' . $language . '_' . $version, function() use ($app, $section, $Translator) { - $markdown_html = $app->parsedown()->text($Translator->getMarkdownLanguageFile($section . '.md')); - $markdown_html = Text::addClassesToElements($markdown_html); - return $markdown_html; - }, 86400); // 1 day + $app->eventDispatcher()->trigger('flight.cache.checked', 'compile_single_page_' . $cacheKey, $cacheHit, microtime(true) - $cacheStartTime); $markdown_html = $this->wrapContentInDiv($markdown_html); $this->renderPage('single_page.latte', [ 'page_title' => $section, 'markdown' => $markdown_html, - 'version' => $version, + 'version' => $version, ]); } - /** - * Compiles the Scrollspy page based on the provided language, version, section, and sub-section. - * - * @param string $language The language of the documentation. - * @param string $version The version of the documentation. - * @param string $section The main section of the documentation. - * @param string $sub_section The sub-section of the documentation. - */ + /** + * Compiles the Scrollspy page based on the provided language, version, section, and sub-section. + * + * @param string $language The language of the documentation. + * @param string $version The version of the documentation. + * @param string $section The main section of the documentation. + * @param string $sub_section The sub-section of the documentation. + */ public function compileScrollspyPage(string $language, string $version, string $section, string $sub_section) { $app = $this->app; - $Translator = $this->setupTranslatorService($language, $version); + // Check if the language is valid + if ($this->checkValidLanguage($language) === false) { + $language = 'en'; + } + + // Check if the version is valid + if ($this->checkValidVersion($version) === false) { + $version = 'v3'; + } + + $Translator = $this->setupTranslatorService($language, $version); $section_file_path = str_replace('_', '-', $section); $sub_section_underscored = str_replace('-', '_', $sub_section); $heading_data = $app->cache()->retrieve($sub_section_underscored . '_heading_data_' . $language . '_' . $version); - $markdown_html = $app->cache()->refreshIfExpired($sub_section_underscored . '_html_' . $language . '_' . $version, function () use ($app, $section_file_path, $sub_section, $sub_section_underscored, &$heading_data, $language, $Translator, $version) { - $parsed_text = $app->parsedown()->text($Translator->getMarkdownLanguageFile('/' . $section_file_path . '/' . $sub_section_underscored . '.md')); + + $cacheStartTime = microtime(true); + $cacheHit = true; + $cacheKey = $sub_section_underscored . '_html_' . $language . '_' . $version; + $markdown_html = $app->cache()->retrieve($cacheKey); + if ($markdown_html === null) { + $cacheHit = false; + $markdown_html = $app->parsedown()->text($Translator->getMarkdownLanguageFile('/' . $section_file_path . '/' . $sub_section_underscored . '.md')); $heading_data = []; - $parsed_text = Text::generateAndConvertHeaderListFromHtml($parsed_text, $heading_data, 'h2', $section_file_path.'/'.$sub_section); - $parsed_text = Text::addClassesToElements($parsed_text); + $markdown_html = Text::generateAndConvertHeaderListFromHtml($markdown_html, $heading_data, 'h2', $section_file_path . '/' . $sub_section); + $markdown_html = Text::addClassesToElements($markdown_html); $app->cache()->store($sub_section_underscored . '_heading_data_' . $language . '_' . $version, $heading_data, 86400); // 1 day + $app->cache()->store($cacheKey, $markdown_html, 86400); // 1 day + } - return $parsed_text; - }, 86400); // 1 day + $app->eventDispatcher()->trigger('flight.cache.checked', 'compile_scrollspy_page_' . $cacheKey, $cacheHit, microtime(true) - $cacheStartTime); // pull the title out of the first h1 tag $page_title = ''; @@ -123,17 +167,17 @@ public function compileScrollspyPage(string $language, string $version, string $ 'custom_page_title' => ($page_title ? $page_title . ' - ' : '') . $Translator->translate($section), 'markdown' => $markdown_html, 'heading_data' => $heading_data, - 'relative_uri' => '/'.$section_file_path, - 'version' => $version, + 'relative_uri' => '/' . $section_file_path, + 'version' => $version, ]); } - /** + /** * This is necessary to encapsulate contents (

,

, 
    ,