diff --git a/.gitignore b/.gitignore index 3f431137..488ba1b8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ /cache/* !/cache/.placeholder -# App Docs # +# Documentation Cache # /docs/* !/docs/.placeholder diff --git a/assets/scss/_global.scss b/assets/scss/_global.scss index a77f88b2..4011d802 100644 --- a/assets/scss/_global.scss +++ b/assets/scss/_global.scss @@ -27,7 +27,7 @@ a { position: relative; } -nav { +nav.main-nav { background-color: $darker-black; color: #ffffff; padding: .5rem 0; @@ -99,3 +99,66 @@ footer { color: theme-color("primary"); } } + +.sidenav-body { + display: flex; + flex-wrap: wrap; + justify-content: space-around; + .sidebar { + flex-basis: 100%; + @include media-breakpoint-up(md) { + flex-basis: calc(30% - 1vw); + } + @include media-breakpoint-up(lg) { + flex-basis: calc(25% - 1vw); + } + @include media-breakpoint-up(xl) { + flex-basis: calc(20% - 1vw); + } + } + .content { + flex-basis: 100%; + @include media-breakpoint-up(md) { + flex-basis: calc(70% - 1vw); + } + @include media-breakpoint-up(lg) { + flex-basis: calc(75% - 1vw); + } + @include media-breakpoint-up(xl) { + flex-basis: calc(80% - 1vw); + } + } + @supports (display: grid) { + display: grid; + grid-template-columns: 1fr; + grid-gap: 1.25rem; + @include media-breakpoint-up(md) { + grid-template-columns: 30% 70%; + } + @include media-breakpoint-up(lg) { + grid-template-columns: 25% 75%; + } + @include media-breakpoint-up(xl) { + grid-template-columns: 20% 80%; + } + .sidebar { + order: 2; + @include media-breakpoint-up(md) { + order: 1; + } + } + .content { + order: 1; + @include media-breakpoint-up(md) { + order: 2; + } + } + } +} + +/**.highlight { + pre { + font-family: "SFMono-Regular",Consolas,"Liberation Mono",Menlo,Courier,monospace; + @extend .bg-light; + } +}**/ diff --git a/assets/scss/_variables.scss b/assets/scss/_variables.scss index 2ea46b30..8c57d300 100644 --- a/assets/scss/_variables.scss +++ b/assets/scss/_variables.scss @@ -1,23 +1,23 @@ -// Site Custom Variables - -$darker-black: #181818; -$icon-color: #676566; -$go-h2: #d3edfe; -$marker-color: #c1bfbf; - -// Override Bootstrap variables - -$theme-colors: ( - primary: #428bca, - success: #599e1f, - info: #306598 -); - -$body-bg: $darker-black !default; - -$link-color: #214a73; - -$font-family-sans-serif: 'Source Sans Pro', sans-serif; -$font-family-monospace: 'Source Sans Pro', sans-serif; - -$headings-font-weight: 600; +// Site Custom Variables + +$darker-black: #181818; +$icon-color: #676566; +$go-h2: #d3edfe; +$marker-color: #c1bfbf; + +// Override Bootstrap variables + +$theme-colors: ( + primary: #428bca, + success: #599e1f, + info: #306598 +); + +$body-bg: $darker-black !default; + +$link-color: #214a73; + +$font-family-sans-serif: 'Source Sans Pro', sans-serif; +$font-family-monospace: 'Source Sans Pro', sans-serif; + +$headings-font-weight: 600; diff --git a/assets/scss/code.scss b/assets/scss/code.scss new file mode 100644 index 00000000..7b7d439e --- /dev/null +++ b/assets/scss/code.scss @@ -0,0 +1,2 @@ +// Import the theme, customize as necessary afterwards +@import "~github-syntax-light/lib/github-light.css"; diff --git a/assets/scss/pages/_homepage.scss b/assets/scss/pages/_homepage.scss index e0f944b5..f10d0ee1 100644 --- a/assets/scss/pages/_homepage.scss +++ b/assets/scss/pages/_homepage.scss @@ -1,6 +1,19 @@ nav.homepage { ul { li { + margin-left: 5%; + @include media-breakpoint-up(md) { + margin-left: 1%; + } + @include media-breakpoint-up(lg) { + margin-left: 3%; + } + @include media-breakpoint-up(xl) { + margin-left: 4%; + } + &:first-child { + margin-left: 0; + } span { &.title { display: none; diff --git a/assets/scss/template.scss b/assets/scss/template.scss index 02b68d3b..e5837b8f 100644 --- a/assets/scss/template.scss +++ b/assets/scss/template.scss @@ -1,68 +1,70 @@ -// External fonts -@import "_fonts"; - -// Site variables -@import "_variables"; - -// Font Awesome -// From @import "~@fortawesome/fontawesome-free/scss/fontawesome"; -@import '~@fortawesome/fontawesome-free/scss/variables'; -@import '~@fortawesome/fontawesome-free/scss/mixins'; -@import '~@fortawesome/fontawesome-free/scss/core'; -@import "~@fortawesome/fontawesome-free/scss/_larger"; -//@import '~@fortawesome/fontawesome-free/scss/fixed-width'; -//@import '~@fortawesome/fontawesome-free/scss/list'; -//@import '~@fortawesome/fontawesome-free/scss/bordered-pulled'; -//@import '~@fortawesome/fontawesome-free/scss/animated'; -//@import '~@fortawesome/fontawesome-free/scss/rotated-flipped'; -//@import '~@fortawesome/fontawesome-free/scss/stacked'; -@import '~@fortawesome/fontawesome-free/scss/_icons'; -//@import '~@fortawesome/fontawesome-free/scss/screen-reader'; - -@import "~@fortawesome/fontawesome-free/scss/brands"; -@import "~@fortawesome/fontawesome-free/scss/regular"; -@import "~@fortawesome/fontawesome-free/scss/solid"; - -// Bootstrap -@import "~bootstrap/scss/_functions"; -@import "~bootstrap/scss/_variables"; -@import "~bootstrap/scss/_mixins"; -@import "~bootstrap/scss/_root"; -@import "~bootstrap/scss/_reboot"; -@import "~bootstrap/scss/_type"; -@import "~bootstrap/scss/_images"; -@import "~bootstrap/scss/_code"; -@import "~bootstrap/scss/_grid"; -@import "~bootstrap/scss/_tables"; -@import "~bootstrap/scss/_forms"; -@import "~bootstrap/scss/_buttons"; -//@import "~bootstrap/scss/_transitions"; -//@import "~bootstrap/scss/_dropdown"; -//@import "~bootstrap/scss/_button-group"; -//@import "~bootstrap/scss/_input-group"; -//@import "~bootstrap/scss/_custom-forms"; -//@import "~bootstrap/scss/_nav"; -//@import "~bootstrap/scss/_navbar"; -@import "~bootstrap/scss/_card"; -//@import "~bootstrap/scss/_breadcrumb"; -//@import "~bootstrap/scss/_pagination"; -@import "~bootstrap/scss/_badge"; -//@import "~bootstrap/scss/_jumbotron"; -@import "~bootstrap/scss/_alert"; -//@import "~bootstrap/scss/_progress"; -//@import "~bootstrap/scss/_media"; -//@import "~bootstrap/scss/_list-group"; -//@import "~bootstrap/scss/_close"; -//@import "~bootstrap/scss/_toasts"; -//@import "~bootstrap/scss/_modal"; -//@import "~bootstrap/scss/_tooltip"; -//@import "~bootstrap/scss/_popover"; -//@import "~bootstrap/scss/_carousel"; -//@import "~bootstrap/scss/_spinners"; -@import "~bootstrap/scss/_utilities"; -@import "~bootstrap/scss/_print"; - -// Site components -@import "_global"; -@import "_responsive-table"; -@import "pages/_homepage"; +// External fonts +@import "_fonts"; + +// Site variables +@import "_variables"; + +// Font Awesome +// From @import "~@fortawesome/fontawesome-free/scss/fontawesome"; +@import '~@fortawesome/fontawesome-free/scss/variables'; +@import '~@fortawesome/fontawesome-free/scss/mixins'; +@import '~@fortawesome/fontawesome-free/scss/core'; +@import "~@fortawesome/fontawesome-free/scss/_larger"; +//@import '~@fortawesome/fontawesome-free/scss/fixed-width'; +//@import '~@fortawesome/fontawesome-free/scss/list'; +//@import '~@fortawesome/fontawesome-free/scss/bordered-pulled'; +//@import '~@fortawesome/fontawesome-free/scss/animated'; +//@import '~@fortawesome/fontawesome-free/scss/rotated-flipped'; +//@import '~@fortawesome/fontawesome-free/scss/stacked'; +@import '~@fortawesome/fontawesome-free/scss/_icons'; +//@import '~@fortawesome/fontawesome-free/scss/screen-reader'; + +@import "~@fortawesome/fontawesome-free/scss/brands"; +@import "~@fortawesome/fontawesome-free/scss/regular"; +@import "~@fortawesome/fontawesome-free/scss/solid"; + +// Bootstrap +@import "~bootstrap/scss/_functions"; +@import "~bootstrap/scss/_variables"; +@import "~bootstrap/scss/_maps"; +@import "~bootstrap/scss/_mixins"; +@import "~bootstrap/scss/_utilities"; + +@import "~bootstrap/scss/_root"; +@import "~bootstrap/scss/_reboot"; +@import "~bootstrap/scss/_type"; +@import "~bootstrap/scss/_images"; +@import "~bootstrap/scss/_containers"; +@import "~bootstrap/scss/_grid"; +@import "~bootstrap/scss/_tables"; +@import "~bootstrap/scss/_forms"; +@import "~bootstrap/scss/_buttons"; +//@import "~bootstrap/scss/_transitions"; +//@import "~bootstrap/scss/_dropdown"; +//@import "~bootstrap/scss/_button-group"; +//@import "~bootstrap/scss/_nav"; +//@import "~bootstrap/scss/_navbar"; +@import "~bootstrap/scss/_card"; +//@import "~bootstrap/scss/_accordion"; +//@import "~bootstrap/scss/_breadcrumb"; +//@import "~bootstrap/scss/_pagination"; +@import "~bootstrap/scss/_badge"; +@import "~bootstrap/scss/_alert"; +//@import "~bootstrap/scss/_progress"; +//@import "~bootstrap/scss/_list-group"; +//@import "~bootstrap/scss/_close"; +//@import "~bootstrap/scss/_toasts"; +//@import "~bootstrap/scss/_modal"; +//@import "~bootstrap/scss/_tooltip"; +//@import "~bootstrap/scss/_popover"; +//@import "~bootstrap/scss/_carousel"; +//@import "~bootstrap/scss/_spinners"; +//@import "~bootstrap/scss/_offcanvas"; +//@import "~bootstrap/scss/_placeholders"; + +@import "~bootstrap/scss/_helpers"; + +// Site components +@import "_global"; +@import "_responsive-table"; +@import "pages/_homepage"; diff --git a/bin/framework b/bin/framework index abf3feff..90d16db8 100755 --- a/bin/framework +++ b/bin/framework @@ -26,6 +26,7 @@ try { $container = (new Joomla\DI\Container) ->registerServiceProvider(new Joomla\FrameworkWebsite\Service\ApplicationProvider) + ->registerServiceProvider(new Joomla\FrameworkWebsite\Service\CacheProvider) ->registerServiceProvider(new Joomla\FrameworkWebsite\Service\ConfigurationProvider(JPATH_ROOT . '/etc/config.json')) ->registerServiceProvider(new Joomla\Database\Service\DatabaseProvider) ->registerServiceProvider(new Joomla\FrameworkWebsite\Service\EventProvider) diff --git a/composer.json b/composer.json index 6d59966b..fda3a945 100644 --- a/composer.json +++ b/composer.json @@ -1,63 +1,64 @@ -{ - "name": "joomla/framework-website", - "description": "Application code for framework.joomla.org", - "homepage": "http://github.com/joomla/framework.joomla.org", - "license": "GPL-2.0-or-later", - "require": { - "php": "^8.3", - "ext-json": "*", - "ext-pdo": "*", - "fig/link-util": "^1.1", - "joomla/application": "^4.0", - "joomla/console": "^4.0", - "joomla/controller": "^4.0", - "joomla/database": "^4.0", - "joomla/di": "^4.0", - "joomla/event": "^4.0", - "joomla/filesystem": "^4.0", - "joomla/filter": "^4.0", - "joomla/github": "^4.0", - "joomla/http": "^4.0", - "joomla/input": "^4.0", - "joomla/model": "^4.0", - "joomla/preload": "^4.0", - "joomla/registry": "^4.0", - "joomla/renderer": "dev-4.x-dev", - "joomla/router": "^4.0", - "joomla/string": "^4.0", - "joomla/uri": "^4.0", - "joomla/utilities": "^4.0", - "joomla/view": "^4.0", - "laminas/laminas-diactoros": "^3.6.0", - "monolog/monolog": "^2.1", - "psr/link": "^2.0", - "ramsey/uuid": "^4.0.1", - "robmorgan/phinx": "^0.16.1", - "symfony/asset": "^v7.2.0", - "symfony/process": "^7.1.7.0", - "symfony/web-link": "^v7.3.0", - "symfony/yaml": "^v7.3.1", - "theiconic/php-ga-measurement-protocol": "^2.7.2", - "twig/twig": "^3.19.0.0" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^3.59", - "squizlabs/php_codesniffer": "^3.10", - "php-debugbar/php-debugbar": "^v2.2.4", - "phpstan/phpstan": "^2.1.19", - "phpstan/phpstan-deprecation-rules": "^2.0.3" - }, - "replace": { - "paragonie/random_compat": "*" - }, - "autoload": { - "psr-4": { - "Joomla\\FrameworkWebsite\\": "src/" - } - }, - "config": { - "platform": { - "php": "8.3.0" - } - } -} +{ + "name": "joomla/framework-website", + "description": "Application code for framework.joomla.org", + "homepage": "http://github.com/joomla/framework.joomla.org", + "license": "GPL-2.0-or-later", + "require": { + "php": "^8.3", + "ext-json": "*", + "ext-pdo": "*", + "fig/link-util": "^1.1", + "joomla/application": "^4.0", + "joomla/console": "^4.0", + "joomla/controller": "^4.0", + "joomla/database": "^4.0", + "joomla/di": "^4.0", + "joomla/event": "^4.0", + "joomla/filesystem": "^4.0", + "joomla/filter": "^4.0", + "joomla/github": "^4.0", + "joomla/http": "^4.0", + "joomla/input": "^4.0", + "joomla/model": "^4.0", + "joomla/preload": "^4.0", + "joomla/registry": "^4.0", + "joomla/renderer": "dev-4.x-dev", + "joomla/router": "^4.0", + "joomla/string": "^4.0", + "joomla/uri": "^4.0", + "joomla/utilities": "^4.0", + "joomla/view": "^4.0", + "laminas/laminas-diactoros": "^3.6.0", + "monolog/monolog": "^2.1", + "psr/link": "^2.0", + "ramsey/uuid": "^4.0.1", + "robmorgan/phinx": "^0.16.1", + "symfony/asset": "^v7.2.0", + "symfony/cache": "^7.3.1", + "symfony/process": "^7.1.7.0", + "symfony/web-link": "^v7.3.0", + "symfony/yaml": "^v7.3.1", + "theiconic/php-ga-measurement-protocol": "^2.7.2", + "twig/twig": "^3.19.0.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.59", + "squizlabs/php_codesniffer": "^3.10", + "php-debugbar/php-debugbar": "^v2.2.4", + "phpstan/phpstan": "^2.1.19", + "phpstan/phpstan-deprecation-rules": "^2.0.3" + }, + "replace": { + "paragonie/random_compat": "*" + }, + "autoload": { + "psr-4": { + "Joomla\\FrameworkWebsite\\": "src/" + } + }, + "config": { + "platform": { + "php": "8.3.0" + } + } +} diff --git a/composer.lock b/composer.lock index 86837bfa..99abb458 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d98867d7ea9dcf26b02898246a78edf2", + "content-hash": "2bdd0f94b9be5ab163b4fff237f4992d", "packages": [ { "name": "brick/math", @@ -2235,6 +2235,55 @@ ], "time": "2024-11-12T12:43:37+00:00" }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, { "name": "psr/clock", "version": "1.0.0", @@ -3007,6 +3056,180 @@ ], "time": "2025-03-05T10:15:41+00:00" }, + { + "name": "symfony/cache", + "version": "v7.3.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "a7c6caa9d6113cebfb3020b427bcb021ebfdfc9e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/a7c6caa9d6113cebfb3020b427bcb021ebfdfc9e", + "reference": "a7c6caa9d6113cebfb3020b427bcb021ebfdfc9e", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.6", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^6.4|^7.0" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/dependency-injection": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/var-dumper": "<6.4" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/clock": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/filesystem": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "classmap": [ + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v7.3.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-27T19:55:54+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "5d68a57d66910405e5c0b63d6f0af941e66fc868" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/5d68a57d66910405e5c0b63d6f0af941e66fc868", + "reference": "5d68a57d66910405e5c0b63d6f0af941e66fc868", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^3.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-03-13T15:25:07+00:00" + }, { "name": "symfony/config", "version": "v7.3.0", @@ -4020,6 +4243,83 @@ ], "time": "2025-06-27T19:55:54+00:00" }, + { + "name": "symfony/var-exporter", + "version": "v7.3.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "c9a1168891b5aaadfd6332ef44393330b3498c4c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/c9a1168891b5aaadfd6332ef44393330b3498c4c", + "reference": "c9a1168891b5aaadfd6332ef44393330b3498c4c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "symfony/property-access": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v7.3.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-05-15T09:04:05+00:00" + }, { "name": "symfony/web-link", "version": "v7.3.0", diff --git a/docs/.placeholder b/docs/.placeholder new file mode 100644 index 00000000..b0eb8200 --- /dev/null +++ b/docs/.placeholder @@ -0,0 +1 @@ +.placeholder diff --git a/etc/config.dist.json b/etc/config.dist.json index 9b3075db..4ed3d675 100644 --- a/etc/config.dist.json +++ b/etc/config.dist.json @@ -19,7 +19,12 @@ }, "log": { "level": "error", - "application": "error" + "application": "error", + "database": "error" + }, + "cache": { + "enabled": true, + "adapter": "file" }, "github": { "gh": { diff --git a/etc/migrations/20170717031439_release_package_key.php b/etc/migrations/20170717031439_release_package_key.php index 161497e5..fb95acef 100644 --- a/etc/migrations/20170717031439_release_package_key.php +++ b/etc/migrations/20170717031439_release_package_key.php @@ -28,7 +28,7 @@ class ReleasePackageKey extends AbstractMigration public function change() { $this->table('releases') - ->addForeignKey('package_id', 'packages', ['id']) + ->addForeignKey('package_id', 'packages', ['id'], ['delete' => 'CASCADE']) ->update(); } } diff --git a/package-lock.json b/package-lock.json index 1fbbf19e..d9fac4b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,8 +6,9 @@ "": { "license": "GPL-2.0+", "dependencies": { - "@fortawesome/fontawesome-free": "^5.15.4", - "bootstrap": "^4.6.1", + "@fortawesome/fontawesome-free": "~5.15.4", + "bootstrap": "~5.2.3", + "github-syntax-light": "^0.5.0", "smooth-scroll": "^16.1.3" }, "devDependencies": { @@ -2115,6 +2116,16 @@ "node": ">= 8" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@trysound/sax": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", @@ -3174,9 +3185,9 @@ "license": "ISC" }, "node_modules/bootstrap": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.6.2.tgz", - "integrity": "sha512-51Bbp/Uxr9aTuy6ca/8FbFloBUJZLHwnhTcnjIeRn2suQWsWzcuJhGjKDB5eppVte/8oCdOL3VuwxvZDUggwGQ==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.2.3.tgz", + "integrity": "sha512-cEKPM+fwb3cT8NzQZYEu4HilJ3anCrWqh3CHAok1p9jXqMPsPTBhU25fBckEJHJ/p+tTxTFTsFQGM+gaHpi3QQ==", "funding": [ { "type": "github", @@ -3187,10 +3198,8 @@ "url": "https://opencollective.com/bootstrap" } ], - "license": "MIT", "peerDependencies": { - "jquery": "1.9.1 - 3", - "popper.js": "^1.16.1" + "@popperjs/core": "^2.11.6" } }, "node_modules/brace-expansion": { @@ -5212,6 +5221,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/github-syntax-light": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/github-syntax-light/-/github-syntax-light-0.5.0.tgz", + "integrity": "sha512-RLdYWB13q9ruP4vHd7gMAU6kCy71NFY/KWK9RQ7xPROq7sqlqnsL7Clg4szssSK5a9Cs0ys5nxonb3oSK9no2A==" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -6089,13 +6103,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/jquery": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz", - "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==", - "license": "MIT", - "peer": true - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -7248,18 +7255,6 @@ "node": ">=8" } }, - "node_modules/popper.js": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", - "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==", - "deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1", - "license": "MIT", - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, "node_modules/postcss": { "version": "8.4.47", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", diff --git a/package.json b/package.json index 591ab836..bd61d9f5 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,9 @@ "prod": "mix --production" }, "dependencies": { - "@fortawesome/fontawesome-free": "^5.15.4", - "bootstrap": "^4.6.1", + "@fortawesome/fontawesome-free": "~5.15.4", + "bootstrap": "~5.2.3", + "github-syntax-light": "^0.5.0", "smooth-scroll": "^16.1.3" }, "devDependencies": { diff --git a/phinx.php b/phinx.php index ba658b2c..e4142ef6 100644 --- a/phinx.php +++ b/phinx.php @@ -12,6 +12,7 @@ $container = (new Joomla\DI\Container) ->registerServiceProvider(new Joomla\FrameworkWebsite\Service\ApplicationProvider) + ->registerServiceProvider(new Joomla\FrameworkWebsite\Service\CacheProvider) ->registerServiceProvider(new Joomla\FrameworkWebsite\Service\ConfigurationProvider(JPATH_ROOT . '/etc/config.json')) ->registerServiceProvider(new Joomla\Database\Service\DatabaseProvider) ->registerServiceProvider(new Joomla\FrameworkWebsite\Service\EventProvider) diff --git a/src/Cache/Adapter/DebugAdapter.php b/src/Cache/Adapter/DebugAdapter.php new file mode 100644 index 00000000..bbe57c9c --- /dev/null +++ b/src/Cache/Adapter/DebugAdapter.php @@ -0,0 +1,336 @@ +debugBar = $debugBar; + $this->pool = $pool; + } + + /** + * Fetches a value from the pool or computes it if not found. + * + * @param string $key The key of the item to retrieve from the cache + * @param callable|CallbackInterface $callback Should return the computed value for the given key/item + * @param float|null $beta A float that, as it grows, controls the likeliness of triggering + * early expiration. 0 disables it, INF forces immediate expiration. + * The default (or providing null) is implementation dependent but should + * typically be 1.0, which should provide optimal stampede protection. + * @param array $metadata The metadata of the cached item + * + * @return mixed The value corresponding to the provided key + */ + public function get(string $key, callable $callback, float $beta = null, array &$metadata = null): mixed + { + if (!$this->pool instanceof CacheInterface) { + throw new \BadMethodCallException( + sprintf('Cannot call "%s::get()": this class doesn\'t implement "%s".', \get_class($this->pool), CacheInterface::class) + ); + } + + /** @var \DebugBar\DataCollector\TimeDataCollector $collector */ + $collector = $this->debugBar['time']; + + $collector->startMeasure('cache.get ' . $key); + + try { + return $this->pool->get($key, $callback, $beta, $metadata); + } finally { + $collector->stopMeasure('cache.get ' . $key); + } + } + + /** + * Returns a Cache Item representing the specified key. + * + * @param string $key The key for which to return the corresponding Cache Item. + * + * @return CacheItemInterface The corresponding Cache Item. + */ + public function getItem($key): \Symfony\Component\Cache\CacheItem + { + /** @var \DebugBar\DataCollector\TimeDataCollector $collector */ + $collector = $this->debugBar['time']; + + $collector->startMeasure('cache.getItem ' . $key); + + try { + return $this->pool->getItem($key); + } finally { + $collector->stopMeasure('cache.getItem ' . $key); + } + } + + /** + * Confirms if the cache contains specified cache item. + * + * @param string $key The key for which to check existence. + * + * @return boolean + */ + public function hasItem($key): bool + { + /** @var \DebugBar\DataCollector\TimeDataCollector $collector */ + $collector = $this->debugBar['time']; + + $collector->startMeasure('cache.hasItem ' . $key); + + try { + return $this->pool->hasItem($key); + } finally { + $collector->stopMeasure('cache.hasItem ' . $key); + } + } + + /** + * Removes the item from the pool. + * + * @param string $key The key to delete. + * + * @return boolean + */ + public function deleteItem($key): bool + { + /** @var \DebugBar\DataCollector\TimeDataCollector $collector */ + $collector = $this->debugBar['time']; + + $collector->startMeasure('cache.deleteItem ' . $key); + + try { + return $this->pool->deleteItem($key); + } finally { + $collector->stopMeasure('cache.deleteItem ' . $key); + } + } + + /** + * Persists a cache item immediately. + * + * @param CacheItemInterface $item The cache item to save. + * + * @return boolean + */ + public function save(CacheItemInterface $item): bool + { + /** @var \DebugBar\DataCollector\TimeDataCollector $collector */ + $collector = $this->debugBar['time']; + + $collector->startMeasure('cache.save ' . $item->getKey()); + + try { + return $this->pool->save($item); + } finally { + $collector->stopMeasure('cache.save ' . $item->getKey()); + } + } + + /** + * Sets a cache item to be persisted later. + * + * @param CacheItemInterface $item The cache item to save. + * + * @return boolean + */ + public function saveDeferred(CacheItemInterface $item): bool + { + /** @var \DebugBar\DataCollector\TimeDataCollector $collector */ + $collector = $this->debugBar['time']; + + $collector->startMeasure('cache.saveDeferred ' . $item->getKey()); + + try { + return $this->pool->saveDeferred($item); + } finally { + $collector->stopMeasure('cache.saveDeferred ' . $item->getKey()); + } + } + + /** + * Returns a traversable set of cache items. + * + * @param string[] $keys An indexed array of keys of items to retrieve. + * + * @return array A traversable collection of Cache Items keyed by the cache keys of each item. + * A Cache item will be returned for each key, even if that key is not found. + */ + public function getItems(array $keys = []): iterable + { + /** @var \DebugBar\DataCollector\TimeDataCollector $collector */ + $collector = $this->debugBar['time']; + + $collector->startMeasure('cache.getItems ' . implode(', ', $keys)); + + try { + return $this->pool->getItems($keys); + } finally { + $collector->stopMeasure('cache.getItems ' . implode(', ', $keys)); + } + } + + /** + * Deletes all items in the pool. + * + * @return boolean + */ + public function clear(string $prefix = ''): bool + { + /** @var \DebugBar\DataCollector\TimeDataCollector $collector */ + $collector = $this->debugBar['time']; + + $collector->startMeasure('cache.clear'); + + try { + return $this->pool->clear(); + } finally { + $collector->stopMeasure('cache.clear'); + } + } + + /** + * Removes multiple items from the pool. + * + * @param array $keys An array of keys that should be removed from the pool. + * + * @return boolean True if the items were successfully removed. False if there was an error. + */ + public function deleteItems(array $keys): bool + { + /** @var \DebugBar\DataCollector\TimeDataCollector $collector */ + $collector = $this->debugBar['time']; + + $collector->startMeasure('cache.deleteItems ' . implode(', ', $keys)); + + try { + return $this->pool->deleteItems($keys); + } finally { + $collector->stopMeasure('cache.deleteItems ' . implode(', ', $keys)); + } + } + + /** + * Persists any deferred cache items. + * + * @return boolean True if all not-yet-saved items were successfully saved or there were none. False otherwise. + */ + public function commit(): bool + { + /** @var \DebugBar\DataCollector\TimeDataCollector $collector */ + $collector = $this->debugBar['time']; + + $collector->startMeasure('cache.commit'); + + try { + return $this->pool->commit(); + } finally { + $collector->stopMeasure('cache.commit'); + } + } + + /** + * Prune all expired cache items. + * + * @return boolean + */ + public function prune(): bool + { + if (!$this->pool instanceof PruneableInterface) { + return false; + } + + /** @var \DebugBar\DataCollector\TimeDataCollector $collector */ + $collector = $this->debugBar['time']; + + $collector->startMeasure('cache.prune'); + + try { + return $this->pool->prune(); + } finally { + $collector->stopMeasure('cache.prune'); + } + } + + /** + * Reset the cache pool to its original state + * + * @return void + */ + public function reset(): void + { + if (!$this->pool instanceof ResetInterface) { + return; + } + + /** @var \DebugBar\DataCollector\TimeDataCollector $collector */ + $collector = $this->debugBar['time']; + + $collector->startMeasure('cache.reset'); + + try { + $this->pool->reset(); + } finally { + $collector->stopMeasure('cache.reset'); + } + } + + /** + * Delete an item from the cache by its unique key. + * + * @param string $key The unique cache key of the item to delete. + * + * @return boolean + */ + public function delete(string $key): bool + { + /** @var \DebugBar\DataCollector\TimeDataCollector $collector */ + $collector = $this->debugBar['time']; + + $collector->startMeasure('cache.delete ' . $key); + + try { + return $this->pool->deleteItem($key); + } finally { + $collector->stopMeasure('cache.delete ' . $key); + } + } +} diff --git a/src/Command/ClearCacheCommand.php b/src/Command/ClearCacheCommand.php new file mode 100644 index 00000000..f58028c2 --- /dev/null +++ b/src/Command/ClearCacheCommand.php @@ -0,0 +1,72 @@ +cache = $cache; + parent::__construct(); + } + + /** + * Internal function to execute the command. + * + * @param InputInterface $input The input to inject into the command. + * @param OutputInterface $output The output to inject into the command. + * + * @return integer The command exit code + */ + protected function doExecute(InputInterface $input, OutputInterface $output): int + { + $symfonyStyle = new SymfonyStyle($input, $output); + $symfonyStyle->title('Clear Cache'); + $this->cache->clear(); + $symfonyStyle->success('Cache cleared.'); + return 0; + } + + /** + * Configures the current command. + * + * @return void + */ + protected function configure(): void + { + $this->setDescription('Clear the application cache pool'); + } +} diff --git a/src/Command/GitHub/FetchDocsCommand.php b/src/Command/GitHub/FetchDocsCommand.php new file mode 100644 index 00000000..e3307035 --- /dev/null +++ b/src/Command/GitHub/FetchDocsCommand.php @@ -0,0 +1,237 @@ +cache = $cache; + $this->github = $github; + $this->githubHelper = $githubHelper; + $this->packageModel = $packageModel; + parent::__construct(); + } + + /** + * Internal function to execute the command. + * + * @param InputInterface $input The input to inject into the command. + * @param OutputInterface $output The output to inject into the command. + * + * @return integer The command exit code + */ + protected function doExecute(InputInterface $input, OutputInterface $output): int + { + $symfonyStyle = new SymfonyStyle($input, $output); + $symfonyStyle->title('Fetch Package Documentation'); + $packageName = $this->getApplication()->getConsoleInput()->getOption('package'); + if ($packageName) { + try { + $this->processPackage($this->packageModel->getPackage($packageName), $symfonyStyle); + } catch (PackageNotFoundException $exception) { + $symfonyStyle->error(sprintf('There is no `%s` package.', $packageName)); + return 1; + } + } else { + foreach ($this->packageModel->getPackages() as $package) { + $symfonyStyle->comment("Processing {$package->display} package"); + $this->processPackage($package, $symfonyStyle); + } + } + + $symfonyStyle->comment('Cleaning cache'); + $this->cache->deleteItems($this->fileCacheKeys); + $symfonyStyle->success('Update completed.'); + return 0; + } + + /** + * Configures the current command. + * + * @return void + */ + protected function configure(): void + { + $this->setDescription('Fetches package documentation from GitHub'); + $this->addOption('package', 'p', InputOption::VALUE_OPTIONAL, 'Package to limit documentation lookup for'); + $this->setHelp( + <<<'EOF' +The %command.name% command fetches the documentation for Framework packages from GitHub + +php %command.full_name% %command.name% +EOF + ); + } + + /** + * Processes a directory's contents from the repository. + * + * @param string $branch The repository branch to process. + * @param string $version The Framework package version. + * @param string $directory The directory within the repository. + * @param \stdClass $package The package record from the database. + * @param SymfonyStyle $symfonyStyle The I/O object + * + * @return void + */ + private function processDirectory(string $branch, string $version, string $directory, \stdClass $package, SymfonyStyle $symfonyStyle): void + { + try { + $docsDirContents = $this->github->repositories->contents->get('joomla-framework', $package->repo, $directory, $branch); + } catch (UnexpectedResponseException $exception) { + if ($exception->getCode() === 404) { + $symfonyStyle->warning(sprintf('The `%1$s` package does not have documentation for the `%2$s` branch', $package->display, $branch)); + } else { + $symfonyStyle->error(sprintf('Error fetching data for the `%1$s` package\'s `%2$s` branch: %3$s', $package->display, $branch, $exception->getMessage())); + } + + return; + } + + foreach ($docsDirContents as $file) { + switch ($file->type) { + case 'dir': + $this->processDirectory($branch, $version, $file->path, $package, $symfonyStyle); + + break; + case 'file': + $this->processFile($branch, $version, $file->path, $package, $symfonyStyle); + + break; + default: + $symfonyStyle->warning(sprintf('Unsupported file type `%1$s` while processing `%2$s` for the `%3$s` package `%4$s` branch.', $file->type, $file->path, $package->display, $branch)); + + break; + } + } + } + + /** + * Processes a file's contents from the repository. + * + * @param string $branch The repository branch to process. + * @param string $version The Framework package version. + * @param string $path The path to the file within the repository. + * @param \stdClass $package The package record from the database. + * @param SymfonyStyle $symfonyStyle The I/O object + * + * @return void + */ + private function processFile(string $branch, string $version, string $path, \stdClass $package, SymfonyStyle $symfonyStyle): void + { + try { + $file = $this->github->repositories->contents->get('joomla-framework', $package->repo, $path, $branch); + } catch (UnexpectedResponseException $exception) { + $symfonyStyle->error(sprintf('Error fetching data for the `%1$s` package\'s `%2$s` path on the `%3$s` branch: %4$s', $package->display, $path, $branch, $exception->getMessage())); + return; + } + + switch ($file->encoding) { + case 'base64': + $fileContents = base64_decode($file->content); + + break; + default: + $symfonyStyle->warning(sprintf('Unsupported file encoding `%1$s` while processing `%2$s` for the `%3$s` package `%4$s` branch.', $file->encoding, $file->path, $package->display, $branch)); + + return; + } + + $docsPath = JPATH_ROOT . '/docs/' . $version . '/' . str_replace('docs/', $package->package . '/', $file->path); + // Ensure folder exists + Folder::create(\dirname($docsPath)); + if (!file_put_contents($docsPath, $fileContents)) { + $symfonyStyle->error(sprintf('Could not write docs file to `%s`', $docsPath)); + return; + } + + $this->fileCacheKeys[] = $this->githubHelper->generateDocsFileCacheKey($version, $package, substr(str_replace('docs/', '', $file->path), 0, -3)); + } + + /** + * Processes the documentation for a package. + * + * @param \stdClass $package The package record from the database. + * @param SymfonyStyle $symfonyStyle The I/O object + * + * @return void + */ + private function processPackage(\stdClass $package, SymfonyStyle $symfonyStyle): void + { + // Set docs branches + $branches = ['3.x-dev', '4.x-dev']; + $versions = ['3.x', '4.x']; + + foreach ($branches as $key => $branch) { + $this->processDirectory($branch, $versions[$key], 'docs', $package, $symfonyStyle); + } + } +} diff --git a/src/Command/Packagist/SyncCommand.php b/src/Command/Packagist/SyncCommand.php index c3273fd7..4d14e4c4 100644 --- a/src/Command/Packagist/SyncCommand.php +++ b/src/Command/Packagist/SyncCommand.php @@ -87,27 +87,31 @@ protected function doExecute(InputInterface $input, OutputInterface $output): in try { $response = $this->http->get($url); $data = json_decode($response->body); - foreach ($data->package->versions as $versionData) { - // Skip non stable versions - if (!$this->versionIsStable($versionData->version)) { - continue; - } - - // Make sure this release is logged, or update if specified - if ($this->releaseModel->hasRelease($package, $versionData->version)) { - if (!$updateReleases) { + if (!isset($data->package)) { + var_dump($data); + } else { + foreach ($data->package->versions as $versionData) { + // Skip non stable versions + if (!$this->versionIsStable($versionData->version)) { continue; } - $record = $this->releaseModel->getRelease($package, $versionData->version); - $this->releaseModel->updateRelease($record->id, $package, $versionData->version, new \DateTime($versionData->time)); - $updatedReleases++; - $symfonyStyle->comment(sprintf('Updated %1$s package at version %2$s', $package->display, $versionData->version)); - } else { - // Add the release - $this->releaseModel->addRelease($package, $versionData->version, new \DateTime($versionData->time)); - $addedReleases++; - $symfonyStyle->comment(sprintf('Added %1$s package at version %2$s', $package->display, $versionData->version)); + // Make sure this release is logged, or update if specified + if ($this->releaseModel->hasRelease($package, $versionData->version)) { + if (!$updateReleases) { + continue; + } + + $record = $this->releaseModel->getRelease($package, $versionData->version); + $this->releaseModel->updateRelease($record->id, $package, $versionData->version, new \DateTime($versionData->time)); + $updatedReleases++; + $symfonyStyle->comment(sprintf('Updated %1$s package at version %2$s', $package->display, $versionData->version)); + } else { + // Add the release + $this->releaseModel->addRelease($package, $versionData->version, new \DateTime($versionData->time)); + $addedReleases++; + $symfonyStyle->comment(sprintf('Added %1$s package at version %2$s', $package->display, $versionData->version)); + } } } } catch (\RuntimeException $exception) { diff --git a/src/Controller/Documentation/IndexController.php b/src/Controller/Documentation/IndexController.php new file mode 100644 index 00000000..6c3d40bf --- /dev/null +++ b/src/Controller/Documentation/IndexController.php @@ -0,0 +1,57 @@ +view = $view; + } + + /** + * Execute the controller. + * + * @return boolean + */ + public function execute(): bool + { + // Enable browser caching + $this->getApplication()->allowCache(true); + $this->getApplication()->setResponse(new HtmlResponse($this->view->render())); + return true; + } +} diff --git a/src/Controller/Documentation/PageController.php b/src/Controller/Documentation/PageController.php new file mode 100644 index 00000000..3e99061c --- /dev/null +++ b/src/Controller/Documentation/PageController.php @@ -0,0 +1,126 @@ +pageView = $pageView; + $this->githubHelper = $githubHelper; + $this->model = $model; + } + + /** + * Execute the controller. + * + * @return boolean + */ + public function execute(): bool + { + $packageName = $this->getInput()->getString('package'); + $version = $this->getInput()->getString('version'); + $filename = $this->getInput()->getString('filename'); + if (!$packageName || !$version) { + throw new \RuntimeException('Missing required package or version parameters.', 404); + } + + $package = $this->model->getPackage($packageName); + switch ($version) { + case '3.x': + if (!$package->has_v3) { + $this->errorView->setError(sprintf('The %s package does not have a 3.x branch to document.', $package->display)); + $this->getApplication()->setResponse(new HtmlResponse($this->errorView->render(), 404)); + } else { + $this->pageView->setActivePackage($package); + $this->pageView->setPageContent($this->githubHelper->renderDocsFile($version, $package, $filename)); + $this->pageView->setSidebarContent($this->githubHelper->renderDocsFile($version, $package, 'index')); + $this->getApplication()->setResponse(new HtmlResponse($this->pageView->render())); + } + + break; + case '4.x': + if (!$package->has_v4) { + $this->errorView->setError(sprintf('The %s package does not have a 4.x branch to document.', $package->display)); + $this->getApplication()->setResponse(new HtmlResponse($this->errorView->render(), 404)); + } else { + $this->pageView->setActivePackage($package); + $this->pageView->setPageContent($this->githubHelper->renderDocsFile($version, $package, $filename)); + $this->pageView->setSidebarContent($this->githubHelper->renderDocsFile($version, $package, 'index')); + $this->getApplication()->setResponse(new HtmlResponse($this->pageView->render())); + } + + break; + case 'latest': + $this->getApplication()->setResponse(new RedirectResponse($this->getApplication()->get('uri.base.path') . "docs/4.x/{$package->package}/{$filename}")); + + break; + default: + throw new \RuntimeException(sprintf('Unsupported version `%s` for documentation.', $version), 404); + } + + return true; + } +} diff --git a/src/Controller/Documentation/RedirectController.php b/src/Controller/Documentation/RedirectController.php new file mode 100644 index 00000000..9dba1671 --- /dev/null +++ b/src/Controller/Documentation/RedirectController.php @@ -0,0 +1,100 @@ +errorView = $errorView; + $this->model = $model; + } + + /** + * Execute the controller. + * + * @return boolean + */ + public function execute(): bool + { + $packageName = $this->getInput()->getString('package'); + $version = $this->getInput()->getString('version'); + if (!$packageName || !$version) { + throw new \RuntimeException('Missing required package or version parameters.', 404); + } + + $package = $this->model->getPackage($packageName); + switch ($version) { + case '3.x': + if (!$package->has_v2) { + $this->errorView->setError(sprintf('The %s package does not have a 2.x branch to document.', $package->display)); + $this->getApplication()->setResponse(new HtmlResponse($this->errorView->render(), 404)); + } else { + $this->getApplication()->setResponse(new RedirectResponse($this->getApplication()->get('uri.base.path') . "docs/$version/{$package->package}/overview")); + } + + break; + case '4.x': + if (!$package->has_v2) { + $this->errorView->setError(sprintf('The %s package does not have a 2.x branch to document.', $package->display)); + $this->getApplication()->setResponse(new HtmlResponse($this->errorView->render(), 404)); + } else { + $this->getApplication()->setResponse(new RedirectResponse($this->getApplication()->get('uri.base.path') . "docs/$version/{$package->package}/overview")); + } + + break; + case 'latest': + $this->getApplication()->setResponse(new RedirectResponse($this->getApplication()->get('uri.base.path') . "docs/4.x/{$package->package}/overview")); + + break; + default: + throw new \RuntimeException(sprintf('Unsupported version `%s` for documentation.', $version), 404); + } + + return true; + } +} diff --git a/src/DebugWebApplication.php b/src/DebugWebApplication.php index 9a623340..30f87dfe 100644 --- a/src/DebugWebApplication.php +++ b/src/DebugWebApplication.php @@ -59,11 +59,11 @@ public function __construct(DebugBar $debugBar, ControllerResolverInterface $con */ protected function doExecute(): void { - $route = $this->router->parseRoute($this->get('uri.route', ''), $this->input->getMethod()); + $route = $this->router->parseRoute($this->get('uri.route', ''), $this->getInput()->getMethod()); // Add variables to the input if not already set foreach ($route->getRouteVariables() as $key => $value) { - $this->input->def($key, $value); + $this->getInput()->def($key, $value); } $controller = $this->controllerResolver->resolve($route); diff --git a/src/EventListener/ErrorSubscriber.php b/src/EventListener/ErrorSubscriber.php index 5463a967..0d283f5d 100644 --- a/src/EventListener/ErrorSubscriber.php +++ b/src/EventListener/ErrorSubscriber.php @@ -87,7 +87,7 @@ public function handleWebError(ApplicationErrorEvent $event): void case $event->getError() instanceof MethodNotAllowedException: // Log the error for reference - $this->logger->error(sprintf('Route `%s` not supported by method `%s`', $app->get('uri.route'), $app->input->getMethod()), ['exception' => $event->getError()]); + $this->logger->error(sprintf('Route `%s` not supported by method `%s`', $app->get('uri.route'), $app->getInput()->getMethod()), ['exception' => $event->getError()]); $this->prepareResponse($event); $app->setHeader('Allow', implode(', ', $event->getError()->getAllowedMethods())); @@ -132,7 +132,7 @@ private function prepareResponse(ApplicationErrorEvent $event): void $app = $event->getApplication(); $app->allowCache(false); switch (true) { - case $app->input->getString('_format', 'html') === 'json': + case $app->getInput()->getString('_format', 'html') === 'json': case $app->mimeType === 'application/json': case $app->getResponse() instanceof JsonResponse: $data = [ diff --git a/src/Helper/GitHubHelper.php b/src/Helper/GitHubHelper.php new file mode 100644 index 00000000..978aa580 --- /dev/null +++ b/src/Helper/GitHubHelper.php @@ -0,0 +1,242 @@ +application = $application; + $this->cache = $cache; + $this->database = $database; + $this->github = $github; + } + + /** + * Generate the cache key for a documentation file + * + * @param string $version The Framework version to fetch documentation for. + * @param \stdClass $package The Framework package the documentation belongs to. + * @param string $path The path to the documentation file. + * + * @return string + */ + public function generateDocsFileCacheKey(string $version, \stdClass $package, string $path): string + { + return str_replace('/', '.', $version . '/' . $package->package . '/' . $path); + } + + /** + * Get the contributor commit count + * + * @return array + */ + public function getCommitCounts(): array + { + return $this->commitCounts; + } + + /** + * Render a documentation file + * + * @param string $version The Framework version to fetch documentation for. + * @param \stdClass $package The Framework package the documentation belongs to. + * @param string $path The path to the documentation file. + * + * @return string + */ + public function renderDocsFile(string $version, \stdClass $package, string $path): string + { + $docsPath = JPATH_ROOT . '/docs/' . $version . '/' . $package->package . '/' . $path . '.md'; + if (!file_exists($docsPath)) { + throw new \InvalidArgumentException(sprintf('No documentation found for `%s` in the `%2$s` package for version `%3$s`.', $path, $package->display, $version), 404); + } + + $key = $this->generateDocsFileCacheKey($version, $package, $path); + $item = $this->cache->getItem($key); + // Make sure we got a hit on the item, otherwise we'll have to re-cache + if ($item->isHit()) { + $rendered = $item->get(); + } else { + $rendered = $this->github->markdown->render(file_get_contents($docsPath), 'gfm', 'joomla-framework/' . $package->repo); + $routePrefix = $this->application->get('uri.base.path') . 'docs/' . $version . '/' . $package->package . '/'; + // Fix links - TODO: This should only change relative links for the docs files + $rendered = preg_replace('/href=\"(.*)\.md\"/', 'href="' . $routePrefix . '$1"', $rendered); + // Cache the result for 7 days + $sevenDaysInSeconds = 60 * 60 * 24 * 7; + $item->set($rendered); + $item->expiresAfter($sevenDaysInSeconds); + $this->cache->save($item); + } + + return $rendered; + } + + /** + * Sync the contributors for a package + * + * @param string $package The package to synchronize + * + * @return void + * + * @throws ExecutionFailureException + */ + public function syncPackageContributors(string $package): void + { + $contributors = $this->github->repositories->getListContributors('joomla-framework', $package); + // Begin a transaction in case of error + $this->database->transactionStart(); + try { + foreach ($contributors as $contributor) { + if (\in_array($contributor->login, self::IGNORE_ACCOUNTS)) { + continue; + } + + /** @var DatabaseQuery $query */ + $query = $this->database->getQuery(true); + $query->setQuery('INSERT INTO `#__contributors` (github_id, username, avatar, profile) VALUES (:github, :username, :avatar, :profile) ON DUPLICATE KEY UPDATE username = :username, avatar = :avatar, profile = :profile'); + $query->bind('github', $contributor->id, ParameterType::INTEGER); + $query->bind('username', $contributor->login, ParameterType::STRING); + $query->bind('avatar', $contributor->avatar_url, ParameterType::STRING); + $query->bind('profile', $contributor->html_url, ParameterType::STRING); + $this->database->setQuery($query)->execute(); + if (isset($this->commitCounts[$contributor->login])) { + $this->commitCounts[$contributor->login] += $contributor->contributions; + } else { + $this->commitCounts[$contributor->login] = $contributor->contributions; + } + } + + $this->database->transactionCommit(); + } catch (ExecutionFailureException $exception) { + $this->database->transactionRollback(); + throw $exception; + } + } + + /** + * Sync the contributor user data + * + * @return void + * + * @throws ExecutionFailureException + */ + public function syncUserData(): void + { + $query = $this->database->getQuery(true); + $query->select($this->database->quoteName(['username'])) + ->from($this->database->quoteName('#__contributors')); + $usernames = $this->database->setQuery($query)->loadColumn(); + $this->database->transactionStart(); + try { + foreach ($usernames as $username) { + $userData = $this->github->users->get($username); + $query = $this->database->getQuery(true); + $query->update($this->database->quoteName('#__contributors')) + ->set($this->database->quoteName('name') . ' = :name') + ->where($this->database->quoteName('username') . ' = :username'); + $name = $userData->name ?: ''; + $query->bind('name', $name, ParameterType::STRING); + $query->bind('username', $username, ParameterType::STRING); + $this->database->setQuery($query)->execute(); + } + + $this->database->transactionCommit(); + } catch (ExecutionFailureException $exception) { + $this->database->transactionRollback(); + throw $exception; + } + } + + /** + * Update the stored commit counts for contributors + * + * @return void + * + * @throws ExecutionFailureException + */ + public function updateCommitCounts(): void + { + $this->database->transactionStart(); + try { + foreach ($this->getCommitCounts() as $username => $count) { + $query = $this->database->getQuery(true); + $query->update($this->database->quoteName('#__contributors')) + ->set($this->database->quoteName('commits') . ' = :commits') + ->where($this->database->quoteName('username') . ' = :username'); + $query->bind('username', $username, ParameterType::STRING); + $query->bind('commits', $count, ParameterType::INTEGER); + $this->database->setQuery($query)->execute(); + } + + $this->database->transactionCommit(); + } catch (ExecutionFailureException $exception) { + $this->database->transactionRollback(); + throw $exception; + } + } +} diff --git a/src/Http/HttpFactory.php b/src/Http/HttpFactory.php new file mode 100644 index 00000000..e03e8537 --- /dev/null +++ b/src/Http/HttpFactory.php @@ -0,0 +1,53 @@ +debugBar = $debugBar; + } + + /** + * Finds an available TransportInterface object for communication + * + * @param array|\ArrayAccess $options Options for creating TransportInterface object + * @param array|string $default Adapter (string) or queue of adapters (array) to use + * + * @return TransportInterface|boolean Interface sub-class or boolean false if no adapters are available + * + * @throws \InvalidArgumentException + */ + public function getAvailableDriver($options = [], $default = null) + { + $wrappedDriver = parent::getAvailableDriver($options, $default); + return new DebugTransport($this->debugBar, $wrappedDriver, $options); + } +} diff --git a/src/Http/Transport/DebugTransport.php b/src/Http/Transport/DebugTransport.php new file mode 100644 index 00000000..eda003e0 --- /dev/null +++ b/src/Http/Transport/DebugTransport.php @@ -0,0 +1,84 @@ +debugBar = $debugBar; + $this->wrappedTransport = $wrappedTransport; + } + + /** + * Send a request to the server and return a Response object with the response. + * + * @param string $method The HTTP method for sending the request. + * @param UriInterface $uri The URI to the resource to request. + * @param mixed $data Either an associative array or a string to be sent with the request. + * @param array $headers An array of request headers to send with the request. + * @param integer $timeout Read timeout in seconds. + * @param string $userAgent The optional user agent string to send with the request. + * + * @return Response + */ + public function request($method, UriInterface $uri, $data = null, array $headers = [], $timeout = null, $userAgent = null) + { + /** @var \DebugBar\DataCollector\TimeDataCollector $collector */ + $collector = $this->debugBar['time']; + $collector->startMeasure($method . ' ' . $uri->toString(['scheme', 'host', 'port', 'path', 'query', 'fragment'])); + try { + $response = $this->wrappedTransport->request($method, $uri, $data, $headers, $timeout, $userAgent); + } finally { + $collector->stopMeasure($method . ' ' . $uri->toString(['scheme', 'host', 'port', 'path', 'query', 'fragment'])); + } + + return $response; + } + + /** + * Method to check if HTTP transport layer available for using + * + * @return boolean True if available else false + */ + public static function isSupported() + { + return true; + } +} diff --git a/src/Model/Exception/PackageNotFoundException.php b/src/Model/Exception/PackageNotFoundException.php new file mode 100644 index 00000000..483dcfc8 --- /dev/null +++ b/src/Model/Exception/PackageNotFoundException.php @@ -0,0 +1,17 @@ +setQuery($query)->loadObject(); if (!$package) { - throw new \RuntimeException(sprintf('Unable to find release data for the `%s` package', $package->display), 404); + throw new PackageNotFoundException(sprintf('Unable to find release data for the `%s` package', $package->display), 404); } return $package; diff --git a/src/Service/ApplicationProvider.php b/src/Service/ApplicationProvider.php index 5f9539b3..b662b37b 100644 --- a/src/Service/ApplicationProvider.php +++ b/src/Service/ApplicationProvider.php @@ -9,6 +9,7 @@ namespace Joomla\FrameworkWebsite\Service; +use Joomla\Application\AbstractApplication; use Joomla\Application\AbstractWebApplication; use Joomla\Application\Controller\ContainerControllerResolver; use Joomla\Application\Controller\ControllerResolverInterface; @@ -21,7 +22,9 @@ use Joomla\DI\ServiceProviderInterface; use Joomla\Event\Command\DebugEventDispatcherCommand; use Joomla\Event\DispatcherInterface; +use Joomla\FrameworkWebsite\Command\ClearCacheCommand; use Joomla\FrameworkWebsite\Command\GenerateSriCommand; +use Joomla\FrameworkWebsite\Command\GitHub\FetchDocsCommand; use Joomla\FrameworkWebsite\Command\Package\SyncCommand as PackageSyncCommand; use Joomla\FrameworkWebsite\Command\Package\SyncPullsCommand; use Joomla\FrameworkWebsite\Command\Packagist\DownloadsCommand; @@ -30,15 +33,22 @@ use Joomla\FrameworkWebsite\Command\UpdateCommand; use Joomla\FrameworkWebsite\Controller\Api\PackageControllerGet; use Joomla\FrameworkWebsite\Controller\Api\StatusControllerGet; +use Joomla\FrameworkWebsite\Controller\Documentation\IndexController; +use Joomla\FrameworkWebsite\Controller\Documentation\PageController as DocumentationPageController; +use Joomla\FrameworkWebsite\Controller\Documentation\RedirectController; use Joomla\FrameworkWebsite\Controller\HomepageController; use Joomla\FrameworkWebsite\Controller\PackageController; use Joomla\FrameworkWebsite\Controller\PageController; use Joomla\FrameworkWebsite\Controller\StatusController; use Joomla\FrameworkWebsite\Controller\WrongCmsController; use Joomla\FrameworkWebsite\Helper; +use Joomla\FrameworkWebsite\Helper\GitHubHelper; use Joomla\FrameworkWebsite\Helper\PackagistHelper; use Joomla\FrameworkWebsite\Model\PackageModel; use Joomla\FrameworkWebsite\Model\ReleaseModel; +use Joomla\FrameworkWebsite\View\Documentation\ErrorHtmlView; +use Joomla\FrameworkWebsite\View\Documentation\IndexHtmlView; +use Joomla\FrameworkWebsite\View\Documentation\PageHtmlView; use Joomla\FrameworkWebsite\View\Package\PackageHtmlView; use Joomla\FrameworkWebsite\View\Package\PackageJsonView; use Joomla\FrameworkWebsite\View\Status\StatusHtmlView; @@ -54,6 +64,7 @@ use Joomla\Router\Route; use Joomla\Router\Router; use Joomla\Router\RouterInterface; +use Psr\Cache\CacheItemPoolInterface; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Output\ConsoleOutput; @@ -94,6 +105,8 @@ public function register(Container $container): void ->share(ControllerResolverInterface::class, [$this, 'getControllerResolverService']); $container->alias(Helper::class, 'application.helper') ->share('application.helper', [$this, 'getApplicationHelperService'], true); + $container->alias(GitHubHelper::class, 'application.helper.github') + ->share('application.helper.github', [$this, 'getApplicationHelperGithubService'], true); $container->alias(PackagistHelper::class, 'application.helper.packagist') ->share('application.helper.packagist', [$this, 'getApplicationHelperPackagistService'], true); $container->share('application.packages', [$this, 'getApplicationPackagesService'], true); @@ -107,9 +120,11 @@ public function register(Container $container): void /* * Console Commands */ + $container->share(ClearCacheCommand::class, [$this, 'getClearCacheCommandService'], true); $container->share(DebugEventDispatcherCommand::class, [$this, 'getDebugEventDispatcherCommandService'], true); $container->share(DebugRouterCommand::class, [$this, 'getDebugRouterCommandService'], true); $container->share(DownloadsCommand::class, [$this, 'getDownloadsCommandService'], true); + $container->share(FetchDocsCommand::class, [$this, 'getGitHubFetchDocsCommandService'], true); $container->share(GenerateSriCommand::class, [$this, 'getGenerateSriCommandService'], true); $container->share(PackageSyncCommand::class, [$this, 'getPackageSyncCommandService'], true); $container->share(SyncPullsCommand::class, [$this, 'getPullSyncCommandService'], true); @@ -125,6 +140,12 @@ public function register(Container $container): void ->share('controller.api.package', [$this, 'getControllerApiPackageService'], true); $container->alias(StatusControllerGet::class, 'controller.api.status') ->share('controller.api.status', [$this, 'getControllerApiStatusService'], true); + $container->alias(IndexController::class, 'controller.documentation.index') + ->share('controller.documentation.index', [$this, 'getControllerDocumentationIndexService'], true); + $container->alias(DocumentationPageController::class, 'controller.documentation.page') + ->share('controller.documentation.page', [$this, 'getControllerDocumentationPageService'], true); + $container->alias(RedirectController::class, 'controller.documentation.redirect') + ->share('controller.documentation.redirect', [$this, 'getControllerDocumentationRedirectService'], true); $container->alias(HomepageController::class, 'controller.homepage') ->share('controller.homepage', [$this, 'getControllerHomepageService'], true); $container->alias(PackageController::class, 'controller.package') @@ -141,6 +162,12 @@ public function register(Container $container): void $container->alias(ReleaseModel::class, 'model.release') ->share('model.release', [$this, 'getModelReleaseService'], true); // Views + $container->alias(ErrorHtmlView::class, 'view.documentation.error.html') + ->share('view.documentation.error.html', [$this, 'getViewDocumentationErrorHtmlService'], true); + $container->alias(IndexHtmlView::class, 'view.documentation.index.html') + ->share('view.documentation.index.html', [$this, 'getViewDocumentationIndexHtmlService'], true); + $container->alias(PageHtmlView::class, 'view.documentation.page.html') + ->share('view.documentation.page.html', [$this, 'getViewDocumentationPageHtmlService'], true); $container->alias(PackageHtmlView::class, 'view.package.html') ->share('view.package.html', [$this, 'getViewPackageHtmlService'], true); $container->alias(PackageJsonView::class, 'view.package.json') @@ -225,6 +252,9 @@ public function getApplicationRouterService(Container $container): RouterInterfa * Web routes */ $router->addRoute(new Route(['GET', 'HEAD'], '/', HomepageController::class)); + $router->get('/docs', IndexController::class); + $router->get('/docs/:version/:package', RedirectController::class); + $router->get('/docs/:version/:package/:filename', DocumentationPageController::class, ['filename' => '.*']); $router->get('/status', StatusController::class); $router->get('/:view', PageController::class); $router->get('/status/:package', PackageController::class); @@ -250,9 +280,11 @@ public function getApplicationRouterService(Container $container): RouterInterfa public function getCommandLoaderService(Container $container): LoaderInterface { $mapping = [ + ClearCacheCommand::getDefaultName() => ClearCacheCommand::class, DebugEventDispatcherCommand::getDefaultName() => DebugEventDispatcherCommand::class, DebugRouterCommand::getDefaultName() => DebugRouterCommand::class, DownloadsCommand::getDefaultName() => DownloadsCommand::class, + FetchDocsCommand::getDefaultName() => FetchDocsCommand::class, PackageSyncCommand::getDefaultName() => PackageSyncCommand::class, PackagistSyncCommand::getDefaultName() => PackagistSyncCommand::class, SyncPullsCommand::getDefaultName() => SyncPullsCommand::class, @@ -294,20 +326,6 @@ public function getControllerApiPackageService(Container $container): PackageCon return $controller; } - /** - * Get the `controller.api.status` service - * - * @param Container $container The DI container. - * - * @return StatusControllerGet - */ - public function getControllerApiStatusService(Container $container): StatusControllerGet - { - $controller = new StatusControllerGet($container->get(StatusJsonView::class), $container->get(Analytics::class), $container->get(Input::class), $container->get(WebApplication::class)); - $controller->setLogger($container->get(LoggerInterface::class)); - return $controller; - } - /** * Get the `controller.homepage` service * @@ -365,7 +383,11 @@ public function getControllerResolverService(Container $container): ControllerRe */ public function getControllerStatusService(Container $container): StatusController { - return new StatusController($container->get(StatusHtmlView::class), $container->get(Input::class), $container->get(WebApplication::class)); + return new StatusController( + $container->get(StatusHtmlView::class), + $container->get(Input::class), + $container->get(WebApplication::class) + ); } /** @@ -473,7 +495,10 @@ public function getModelReleaseService(Container $container): ReleaseModel */ public function getPackageSyncCommandService(Container $container): PackageSyncCommand { - return new PackageSyncCommand($container->get(Helper::class), $container->get(PackageModel::class)); + return new PackageSyncCommand( + $container->get(Helper::class), + $container->get(PackageModel::class) + ); } /** @@ -575,6 +600,22 @@ public function getViewStatusJsonService(Container $container): StatusJsonView { return new StatusJsonView($container->get('model.package'), $container->get('model.release')); } + /** + * Get the `application.helper.github` service + * + * @param Container $container The DI container. + * + * @return GitHubHelper + */ + public function getApplicationHelperGithubService(Container $container): GitHubHelper + { + return new GitHubHelper( + $container->get(Github::class), + $container->get(DatabaseInterface::class), + $container->get(CacheItemPoolInterface::class), + $container->get(AbstractApplication::class) + ); + } /** * Get the WebApplication class service @@ -593,6 +634,166 @@ public function getWebApplicationClassService(Container $container): WebApplicat return $application; } + /** + * Get the ClearCacheCommand service + * + * @param Container $container The DI container. + * + * @return ClearCacheCommand + */ + public function getClearCacheCommandService(Container $container): ClearCacheCommand + { + return new ClearCacheCommand($container->get(CacheItemPoolInterface::class)); + } + + /** + * Get the `controller.api.status` service + * + * @param Container $container The DI container. + * + * @return StatusControllerGet + */ + public function getControllerApiStatusService(Container $container): StatusControllerGet + { + $controller = new StatusControllerGet( + $container->get(StatusJsonView::class), + $container->get(Analytics::class), + $container->get(Input::class), + $container->get(WebApplication::class) + ); + + $controller->setLogger($container->get(LoggerInterface::class)); + + return $controller; + } + + + /** + * Get the `controller.documentation.index` service + * + * @param Container $container The DI container. + * + * @return IndexController + */ + public function getControllerDocumentationIndexService(Container $container): IndexController + { + return new IndexController( + $container->get(IndexHtmlView::class), + $container->get(Input::class), + $container->get(WebApplication::class) + ); + } + + /** + * Get the `controller.documentation.page` service + * + * @param Container $container The DI container. + * + * @return DocumentationPageController + */ + public function getControllerDocumentationPageService(Container $container): DocumentationPageController + { + return new DocumentationPageController( + $container->get(PackageModel::class), + $container->get(ErrorHtmlView::class), + $container->get(PageHtmlView::class), + $container->get(GitHubHelper::class), + $container->get(Input::class), + $container->get(WebApplication::class) + ); + } + + /** + * Get the `controller.documentation.redirect` service + * + * @param Container $container The DI container. + * + * @return RedirectController + */ + public function getControllerDocumentationRedirectService(Container $container): RedirectController + { + return new RedirectController( + $container->get(PackageModel::class), + $container->get(ErrorHtmlView::class), + $container->get(Input::class), + $container->get(WebApplication::class) + ); + } + + /** + * Get the FetchDocsCommand service + * + * @param Container $container The DI container. + * + * @return FetchDocsCommand + */ + public function getGitHubFetchDocsCommandService(Container $container): FetchDocsCommand + { + return new FetchDocsCommand( + $container->get(PackageModel::class), + $container->get(Github::class), + $container->get(GitHubHelper::class), + $container->get(CacheItemPoolInterface::class) + ); + } + + /** + * Get the `view.documentation.error.html` service + * + * @param Container $container The DI container. + * + * @return ErrorHtmlView + */ + public function getViewDocumentationErrorHtmlService(Container $container): ErrorHtmlView + { + $view = new ErrorHtmlView( + $container->get('model.package'), + $container->get('renderer') + ); + + $view->setLayout('docs/error.twig'); + + return $view; + } + + /** + * Get the `view.documentation.index.html` service + * + * @param Container $container The DI container. + * + * @return IndexHtmlView + */ + public function getViewDocumentationIndexHtmlService(Container $container): IndexHtmlView + { + $view = new IndexHtmlView( + $container->get('model.package'), + $container->get('renderer') + ); + + $view->setLayout('docs/index.twig'); + + return $view; + } + + /** + * Get the `view.documentation.page.html` service + * + * @param Container $container The DI container. + * + * @return PageHtmlView + */ + public function getViewDocumentationPageHtmlService(Container $container): PageHtmlView + { + $view = new PageHtmlView( + $container->get('model.package'), + $container->get('renderer') + ); + + $view->setLayout('docs/page.twig'); + + return $view; + } + /** * Get the web client service * diff --git a/src/Service/CacheProvider.php b/src/Service/CacheProvider.php new file mode 100644 index 00000000..dfde0175 --- /dev/null +++ b/src/Service/CacheProvider.php @@ -0,0 +1,79 @@ +alias('cache', CacheItemPoolInterface::class) + ->alias(AdapterInterface::class, CacheItemPoolInterface::class) + ->share(CacheItemPoolInterface::class, [$this, 'getCacheService']); + } + + /** + * Get the `cache` service + * + * @param Container $container The DI container. + * + * @return CacheItemPoolInterface + * + * @throws \InvalidArgumentException + */ + public function getCacheService(Container $container): CacheItemPoolInterface + { + /** @var \Joomla\Registry\Registry $config */ + $config = $container->get('config'); + // If caching isn't enabled then just return a void cache + if (!$config->get('cache.enabled', false)) { + return new NullAdapter(); + } + + $adapter = $config->get('cache.adapter', 'file'); + $lifetime = $config->get('cache.lifetime', 900); + $namespace = $config->get('cache.namespace', 'jfw'); + switch ($adapter) { + case 'file': + $path = $config->get('cache.file.path', JPATH_ROOT . '/cache/pool'); + // If no path is given, fall back to the system's temporary directory + if (empty($path)) { + $path = sys_get_temp_dir(); + } + + return new FilesystemAdapter($namespace, $lifetime, $path); + case 'none': + return new NullAdapter(); + case 'runtime': + return new ArrayAdapter($lifetime); + } + + throw new InvalidArgumentException(sprintf('The "%s" cache adapter is not supported.', $adapter)); + } +} diff --git a/src/Service/DebugBarProvider.php b/src/Service/DebugBarProvider.php index e2b58d52..d2955e3f 100644 --- a/src/Service/DebugBarProvider.php +++ b/src/Service/DebugBarProvider.php @@ -23,14 +23,18 @@ use Joomla\DI\Exception\DependencyResolutionException; use Joomla\DI\ServiceProviderInterface; use Joomla\Event\DispatcherInterface; +use Joomla\FrameworkWebsite\Cache\Adapter\DebugAdapter; use Joomla\FrameworkWebsite\Controller\DebugControllerResolver; use Joomla\FrameworkWebsite\DebugBar\JoomlaHttpDriver; use Joomla\FrameworkWebsite\DebugWebApplication; use Joomla\FrameworkWebsite\Event\DebugDispatcher; use Joomla\FrameworkWebsite\EventListener\DebugSubscriber; +use Joomla\FrameworkWebsite\Http\HttpFactory; use Joomla\FrameworkWebsite\Router\DebugRouter; +use Joomla\Http\HttpFactory as BaseHttpFactory; use Joomla\Input\Input; use Joomla\Router\RouterInterface; +use Psr\Cache\CacheItemPoolInterface; use Psr\Log\LoggerInterface; use Twig\Extension\ProfilerExtension; @@ -61,6 +65,7 @@ public function register(Container $container): void ->share('debug.http.driver', [$this, 'getDebugHttpDriverService'], true); $container->alias(DebugSubscriber::class, 'event.subscriber.debug') ->share('event.subscriber.debug', [$this, 'getEventSubscriberDebugService'], true); + $container->extend(CacheItemPoolInterface::class, [$this, 'getDecoratedCacheService']); $container->extend(ControllerResolverInterface::class, [$this, 'getDecoratedControllerResolverService']); $container->extend(DispatcherInterface::class, [$this, 'getDecoratedDispatcherService']); $container->extend(AbstractWebApplication::class, [$this, 'getDecoratedWebApplicationService']); @@ -154,6 +159,19 @@ public function getDebugHttpDriverService(Container $container): JoomlaHttpDrive return new JoomlaHttpDriver($container->get(AbstractWebApplication::class)); } + /** + * Get the decorated `cache` service + * + * @param CacheItemPoolInterface $cache The original CacheItemPoolInterface service. + * @param Container $container The DI container. + * + * @return CacheItemPoolInterface + */ + public function getDecoratedCacheService(CacheItemPoolInterface $cache, Container $container): CacheItemPoolInterface + { + return new DebugAdapter($container->get('debug.bar'), $cache); + } + /** * Get the decorated controller resolver service * diff --git a/src/View/Documentation/ErrorHtmlView.php b/src/View/Documentation/ErrorHtmlView.php new file mode 100644 index 00000000..0a0f7de2 --- /dev/null +++ b/src/View/Documentation/ErrorHtmlView.php @@ -0,0 +1,70 @@ +packageModel = $packageModel; + } + + /** + * Method to render the view + * + * @return string The rendered view + */ + public function render() + { + $this->setData([ + 'error' => $this->error, + 'packages' => $this->packageModel->getSortedPackages(), + ]); + return parent::render(); + } + + /** + * Set the error message for display + * + * @param string $error The error message to display + * + * @return void + */ + public function setError(string $error): void + { + $this->error = $error; + } +} diff --git a/src/View/Documentation/IndexHtmlView.php b/src/View/Documentation/IndexHtmlView.php new file mode 100644 index 00000000..aa96d96e --- /dev/null +++ b/src/View/Documentation/IndexHtmlView.php @@ -0,0 +1,51 @@ +packageModel = $packageModel; + } + + /** + * Method to render the view + * + * @return string The rendered view + */ + public function render() + { + $this->setData([ + 'packages' => $this->packageModel->getSortedPackages(), + ]); + return parent::render(); + } +} diff --git a/src/View/Documentation/PageHtmlView.php b/src/View/Documentation/PageHtmlView.php new file mode 100644 index 00000000..389d7347 --- /dev/null +++ b/src/View/Documentation/PageHtmlView.php @@ -0,0 +1,108 @@ +packageModel = $packageModel; + } + + /** + * Method to render the view + * + * @return string The rendered view + */ + public function render() + { + $this->setData([ + 'activePackage' => $this->package, + 'contents' => $this->contents, + 'packages' => $this->packageModel->getSortedPackages(), + 'sidebarContents' => $this->sidebarContents, + ]); + return parent::render(); + } + + /** + * Set the active package + * + * @param \stdClass $package The active package for the page + * + * @return void + */ + public function setActivePackage(\stdClass $package): void + { + $this->package = $package; + } + + /** + * Set the content for display + * + * @param string $contents The content to display + * + * @return void + */ + public function setPageContent(string $contents): void + { + $this->contents = $contents; + } + + /** + * Set the sidebar content for display + * + * @param string $contents The content to display + * + * @return void + */ + public function setSidebarContent(string $contents): void + { + $this->sidebarContents = $contents; + } +} diff --git a/src/WebApplication.php b/src/WebApplication.php index 4bf70fc0..6ce397be 100644 --- a/src/WebApplication.php +++ b/src/WebApplication.php @@ -72,10 +72,10 @@ public function __construct(ControllerResolverInterface $controllerResolver, Rou */ protected function doExecute(): void { - $route = $this->router->parseRoute($this->get('uri.route', ''), $this->input->getMethod()); + $route = $this->router->parseRoute($this->get('uri.route', ''), $this->getInput()->getMethod()); // Add variables to the input if not already set foreach ($route->getRouteVariables() as $key => $value) { - $this->input->def($key, $value); + $this->getInput()->def($key, $value); } \call_user_func($this->controllerResolver->resolve($route)); diff --git a/templates/docs/error.twig b/templates/docs/error.twig new file mode 100644 index 00000000..192d11df --- /dev/null +++ b/templates/docs/error.twig @@ -0,0 +1,28 @@ +{% extends 'index.twig' %} + +{% block title %}Joomla! Framework Documentation{% endblock %} + +{% block content %} +
+

Framework Documentation

+ +
+ +
+

We Couldn't Find It

+

{{ error }}

+
+
+
+{% endblock %} diff --git a/templates/docs/index.twig b/templates/docs/index.twig new file mode 100644 index 00000000..94b6c5ea --- /dev/null +++ b/templates/docs/index.twig @@ -0,0 +1,29 @@ +{% extends 'index.twig' %} + +{% block title %}Joomla! Framework Documentation{% endblock %} + +{% block content %} +
+

Framework Documentation

+ +
+ +
+ From this index you can browse the documentation for each of the Framework's packages. +
+
+
+{% endblock %} diff --git a/templates/docs/page.twig b/templates/docs/page.twig new file mode 100644 index 00000000..08eb3435 --- /dev/null +++ b/templates/docs/page.twig @@ -0,0 +1,37 @@ +{% extends 'index.twig' %} + +{% block title %}Joomla! Framework Documentation - {{ activePackage.display }} Package{% endblock %} + +{% block stylesheets %} + +{% endblock %} + +{% block content %} +
+

Framework Documentation - {{ activePackage.display }} Package

+ +
+ +
+ {{ contents|raw }} +
+
+
+{% endblock %} diff --git a/templates/exception.twig b/templates/exception.twig index 831cd715..ab026157 100644 --- a/templates/exception.twig +++ b/templates/exception.twig @@ -1,27 +1,26 @@ -{% extends 'index.twig' %} - -{% block title %}Framework Error{% endblock %} - -{% block content %} -
- {% if exception.code in [404, 405] %} -

We Couldn't Find It

-

Sorry, we couldn't find the page matching your request. Try using the navigation to find what you were looking for?

- {% else %} -

Ouch, That's an Error

-

Well this is embarrassing, seems there was an error processing this request. Perhaps try again? Or file an issue so we can address it.

- {% endif %} - - {% if appDebug %} -

{{ exception.code|default(0) }} {{ exception|get_class }}

-

{{ exception.message|strip_root_path }}

- - {% if exception.previous %} - {% set _previous = exception.previous %} -

Previous Exception

-

{{ exception.code|default(0) }} {{ exception|get_class }}

-

{{ exception.message|strip_root_path }}

- {% endif %} - {% endif %} -
-{% endblock %} +{% extends 'index.twig' %} + +{% block title %}Framework Error{% endblock %} + +{% block content %} +
+ {% if exception.code in [404, 405] %} +

We Couldn't Find It

+

Sorry, we couldn't find the page matching your request. Try using the navigation to find what you were looking for?

+ {% else %} +

Ouch, That's an Error

+

Well this is embarrassing, seems there was an error processing this request. Perhaps try again? Or file an issue so we can address it.

+ {% endif %} + + {% if appDebug %} +

{{ exception.code|default(0) }} {{ exception|get_class }}

+

{{ exception.message|strip_root_path }}

+ {% if exception.previous %} + {% set _previous = exception.previous %} +

Previous Exception

+

{{ exception.code|default(0) }} {{ exception|get_class }}

+

{{ exception.message|strip_root_path }}

+ {% endif %} + {% endif %} +
+{% endblock %} diff --git a/templates/homepage.twig b/templates/homepage.twig index f89603ae..dd69e4f6 100644 --- a/templates/homepage.twig +++ b/templates/homepage.twig @@ -1,12 +1,13 @@ {% extends "index.twig" %} {% block bodyNavigation %} -