diff --git a/Dockerfile.phpdoc b/Dockerfile.phpdoc
index d39ad7891f..60470c5ab6 100644
--- a/Dockerfile.phpdoc
+++ b/Dockerfile.phpdoc
@@ -8,10 +8,7 @@ RUN mkdir /target && \
phpdoc run \
-d 'core/classes' \
-d 'modules/Core/classes' \
- -d 'modules/Discord Integration/classes' \
- -d 'modules/Cookie Consent/classes' \
-d 'modules/Forum/classes' \
- -d 'modules/Members/classes' \
-i vendor \
-t /target
diff --git a/composer.json b/composer.json
index 465a7f960a..ac1066bff8 100644
--- a/composer.json
+++ b/composer.json
@@ -32,7 +32,10 @@
"joypixels/emoji-toolkit": "^7.0",
"geoip2/geoip2": "^2.13",
"jenssegers/agent": "^2.6",
- "php-di/php-di": "^6.4"
+ "illuminate/container": "^8.0",
+ "namelessmc/members-module": "dev-main",
+ "namelessmc/cookie-consent-module": "dev-main",
+ "namelessmc/discord-integration-module": "dev-main"
},
"require-dev": {
"phpstan/phpstan": "1.6.9",
@@ -44,17 +47,12 @@
"autoload": {
"classmap": [
"core/classes",
- "modules/Cookie Consent/classes",
"modules/Core/classes",
"modules/Core/hooks",
"modules/Core/widgets",
- "modules/Discord Integration/classes",
- "modules/Discord Integration/hooks",
- "modules/Discord Integration/widgets",
"modules/Forum/classes",
"modules/Forum/hooks",
- "modules/Forum/widgets",
- "modules/Members/classes"
+ "modules/Forum/widgets"
]
}
}
diff --git a/core/classes/Core/Cache.php b/core/classes/Core/Cache.php
index f55c2db5f1..4743e7d3cd 100644
--- a/core/classes/Core/Cache.php
+++ b/core/classes/Core/Cache.php
@@ -295,6 +295,18 @@ public function retrieveAll(bool $meta = false): array
return $results;
}
+ public function fetch(string $key, callable $callback, int $expiration = 0)
+ {
+ if ($this->isCached($key)) {
+ return $this->retrieve($key);
+ }
+
+ $data = $callback();
+ $this->store($key, $data, $expiration);
+
+ return $data;
+ }
+
/**
* Erase cached entry by its key.
*
diff --git a/core/classes/Core/ComposerModuleDiscovery.php b/core/classes/Core/ComposerModuleDiscovery.php
new file mode 100644
index 0000000000..b50b8e6caa
--- /dev/null
+++ b/core/classes/Core/ComposerModuleDiscovery.php
@@ -0,0 +1,65 @@
+getName(), array_column($allEnabledModules, 'name'))) {
+ continue;
+ }
+
+ self::bootModule($container, $composerModule);
+ }
+ }
+
+ public static function bootModule(\Illuminate\Container\Container $container, ComposerModuleWrapper $composerModule): void
+ {
+ /** @var NamelessMC\Framework\Extend\BaseExtender[] $extenders */
+ $extenders = require_once ROOT_PATH . '/vendor/' . $composerModule->getPackageName() . '/module.php';
+ foreach ($extenders as $extender) {
+ $extender->setModule($composerModule)->extend($container);
+ }
+ }
+
+ public static function fromPackage(array $composerPackage): ComposerModuleWrapper
+ {
+ return new ComposerModuleWrapper(
+ $composerPackage['name'],
+ $composerPackage['extra']['nameless_module']['name'],
+ $composerPackage['extra']['nameless_module']['display_name'],
+ $composerPackage['authors'][0]['name'],
+ $composerPackage['authors'][0]['homepage'],
+ $composerPackage['extra']['nameless_module']['version'],
+ $composerPackage['extra']['nameless_module']['nameless_version'],
+ $composerPackage['source']['url'],
+ );
+ }
+}
diff --git a/core/classes/Core/ComposerModuleWrapper.php b/core/classes/Core/ComposerModuleWrapper.php
new file mode 100644
index 0000000000..848130936a
--- /dev/null
+++ b/core/classes/Core/ComposerModuleWrapper.php
@@ -0,0 +1,180 @@
+_packageName = $packageName;
+ $this->_privateName = $privateName;
+ $this->_authorName = $authorName;
+ $this->_authorHomepage = $authorHomepage;
+ $this->_repositoryUrl = $repositoryUrl;
+
+ parent::__construct($this, $displayName, $authorName, $moduleVersion, $namelessVersion);
+ }
+
+ public function getPackageName(): string
+ {
+ return $this->_packageName;
+ }
+
+ public function getPrivateName(): string
+ {
+ return $this->_privateName;
+ }
+
+ public function getRepositoryUrl(): string
+ {
+ return $this->_repositoryUrl;
+ }
+
+ public function getAuthor(): string
+ {
+ return "{$this->_authorName}";
+ }
+
+ public function setOnInstall(array $callbacks): void
+ {
+ $this->_onInstall = $callbacks;
+ }
+
+ public function setOnEnable(array $callbacks): void
+ {
+ $this->_onEnable = $callbacks;
+ }
+
+ public function setOnDisable(array $callbacks): void
+ {
+ $this->_onDisable = $callbacks;
+ }
+
+ public function setOnUninstall(array $callbacks): void
+ {
+ $this->_onUninstall = $callbacks;
+ }
+
+ public function setDebugInfoProvider(string $provider): void
+ {
+ $this->_debugInfoProvider = $provider;
+ }
+
+ public function onPageLoad(User $user, Pages $pages, Cache $cache, Smarty $smarty, iterable $navs, Widgets $widgets, ?TemplateBase $template)
+ {
+ // ...
+ }
+
+ public function onInstall()
+ {
+ $this->runMigrations();
+ $this->callLifecycleHooks($this->_onInstall);
+ }
+
+ public function onEnable()
+ {
+ $this->callLifecycleHooks($this->_onEnable);
+ }
+
+ public function onDisable()
+ {
+ $this->callLifecycleHooks($this->_onDisable);
+ }
+
+ public function onUninstall()
+ {
+ // TODO, should this be before or after
+ $this->rollbackMigrations();
+ $this->callLifecycleHooks($this->_onUninstall);
+ }
+
+ public function getDebugInfo(): array
+ {
+ if (!$this->_debugInfoProvider) {
+ return [];
+ }
+
+ /** @var \NamelessMC\Framework\Debugging\DebugInfoProvider */
+ $provider = Container::getInstance()->make($this->_debugInfoProvider);
+
+ return $provider->provide();
+ }
+
+ public function frontendViewsPath(): string
+ {
+ return ROOT_PATH . '/vendor/' . $this->_packageName . '/views';
+ }
+
+ public function hasFrontendViews(): bool
+ {
+ return file_exists($this->frontendViewsPath());
+ }
+
+ public function panelViewsPath(): string
+ {
+ return ROOT_PATH . '/vendor/' . $this->_packageName . '/panel_views';
+ }
+
+ public function hasPanelViews(): bool
+ {
+ return file_exists($this->panelViewsPath());
+ }
+
+ private function runMigrations(): void
+ {
+ if (!$this->hasMigrations()) {
+ return;
+ }
+
+ PhinxAdapter::migrate($this->getPrivateName(), $this->migrationsPath());
+ }
+
+ private function rollbackMigrations(): void
+ {
+ if (!$this->hasMigrations()) {
+ return;
+ }
+
+ PhinxAdapter::rollback($this->getPrivateName(), $this->migrationsPath());
+ }
+
+ private function migrationsPath(): string
+ {
+ return ROOT_PATH . '/vendor/' . $this->_packageName . '/migrations';
+ }
+
+ private function hasMigrations(): bool
+ {
+ return file_exists($this->migrationsPath());
+ }
+
+ private function callLifecycleHooks(array $hooks): void
+ {
+ foreach ($hooks as $callback) {
+ /** @var \NamelessMC\Framework\ModuleLifecycle\Hook $hook */
+ $hook = Container::getInstance()->make($callback);
+ $hook->execute();
+ }
+ }
+}
diff --git a/core/classes/Core/Navigation.php b/core/classes/Core/Navigation.php
index 185ea56b0e..7138b89be5 100644
--- a/core/classes/Core/Navigation.php
+++ b/core/classes/Core/Navigation.php
@@ -24,6 +24,8 @@ class Navigation
*/
private bool $_panel;
+ private array $_preloaded_dropdowns = [];
+
public function __construct(bool $panel = false)
{
$this->_panel = $panel;
@@ -49,19 +51,6 @@ public function add(
float $order = 10,
?string $icon = ''
): void {
- if ($this->_panel && $location == 'top') {
- // Discard order
- // TODO: only a temporary solution to the link conflict issue in the StaffCP
- if (count($this->_topNavbar)) {
- $key = array_keys($this->_topNavbar)[count($this->_topNavbar) - 1];
- $previous_order = $this->_topNavbar[$key]['order'];
- } else {
- $previous_order = 0;
- }
-
- $order = $previous_order + 1;
- }
-
// Add the link to the navigation
if ($location === 'top') {
// Add to top navbar
@@ -143,15 +132,28 @@ public function addDropdown(string $name, string $title, string $location = 'top
public function addItemToDropdown(string $dropdown, string $name, string $title, string $link, string $location = 'top', string $target = null, string $icon = '', int $order = 10): void
{
// Add the item
- if ($location == 'top' && isset($this->_topNavbar[$dropdown])) {
- // Navbar
- $this->_topNavbar[$dropdown]['items'][$name] = [
- 'title' => $title,
- 'link' => $link,
- 'target' => $target,
- 'icon' => $icon,
- 'order' => $order,
- ];
+ if ($location == 'top') {
+ if (isset($this->_topNavbar[$dropdown])) {
+ // Navbar
+ $this->_topNavbar[$dropdown]['items'][$name] = [
+ 'title' => $title,
+ 'link' => $link,
+ 'target' => $target,
+ 'icon' => $icon,
+ 'order' => $order,
+ ];
+ } else {
+ // Dropdown not found
+ if (!isset($this->_preloaded_dropdowns[$dropdown])) {
+ $this->_preloaded_dropdowns[$dropdown]['items'][$name] = [
+ 'title' => $title,
+ 'link' => $link,
+ 'items' => [],
+ 'icon' => $icon,
+ 'order' => $order,
+ ];
+ }
+ }
} elseif (isset($this->_footerNav[$dropdown])) {
// Footer
$this->_footerNav[$dropdown]['items'][$name] = [
@@ -172,6 +174,15 @@ public function addItemToDropdown(string $dropdown, string $name, string $title,
*/
public function returnNav(string $location = 'top'): array
{
+ // merge preloaded dropdowns
+ foreach ($this->_preloaded_dropdowns as $key => $dropdown) {
+ if ($location == 'top') {
+ if (isset($this->_topNavbar[$key])) {
+ $this->_topNavbar[$key]['items'] = array_merge($this->_topNavbar[$key]['items'], $dropdown['items']);
+ }
+ }
+ }
+
$return = []; // String to return
if ($location == 'top') {
if (count($this->_topNavbar)) {
diff --git a/core/classes/Core/Pages.php b/core/classes/Core/Pages.php
index 88e0309ac6..243466627a 100644
--- a/core/classes/Core/Pages.php
+++ b/core/classes/Core/Pages.php
@@ -42,14 +42,18 @@ class Pages
* @param string $file Path (from module folder) to page file.
* @param string $name Name of page.
* @param bool $widgets Can widgets be used on the page? Default false.
+ * @param bool $controllerBased Is the page controller based? Default false.
*/
- public function add(string $module, string $url, string $file, string $name = '', bool $widgets = false): void
+ public function add(string $module, string $url, string $file, string $name = '', bool $widgets = false, string $moduleSafeName = null, bool $controllerBased = false, string $safeName = ''): void
{
$this->_pages[$url] = [
'module' => $module,
+ 'moduleSafeName' => $moduleSafeName,
'file' => $file,
'name' => $name,
+ 'safeName' => $safeName,
'widgets' => $widgets,
+ 'controllerBased' => $controllerBased,
'id' => $this->_id++,
];
}
diff --git a/core/classes/Database/PhinxAdapter.php b/core/classes/Database/PhinxAdapter.php
index fea7a6f2a6..6ae3662c65 100644
--- a/core/classes/Database/PhinxAdapter.php
+++ b/core/classes/Database/PhinxAdapter.php
@@ -10,14 +10,12 @@ class PhinxAdapter
*
* @param string $module Module name
* @param ?string $migrationDir Migration directory
- * @param bool $returnResults If true the results will be returned - otherwise script execution is ended
*
* @return array|void
*/
public static function ensureUpToDate(
string $module,
- ?string $migrationDir = null,
- bool $returnResults = false
+ ?string $migrationDir = null
) {
$module = strtolower($module);
@@ -52,13 +50,6 @@ static function ($file_name) {
$missing = array_diff($migration_files, $migration_database_entries);
$extra = array_diff($migration_database_entries, $migration_files);
- if ($returnResults) {
- return [
- 'missing' => count($missing),
- 'extra' => count($extra),
- ];
- }
-
// Likely a pull from the repo dev branch or migrations
// weren't run during an upgrade script.
if (($missing_count = count($missing)) > 0) {
diff --git a/core/classes/Debugging/DebugInfoProvider.php b/core/classes/Debugging/DebugInfoProvider.php
new file mode 100644
index 0000000000..e55cfdb6e8
--- /dev/null
+++ b/core/classes/Debugging/DebugInfoProvider.php
@@ -0,0 +1,8 @@
+getFilename());
- try {
- /** @var EndpointBase $endpoint */
- $endpoint = new $endpoint_class_name();
+ $this->loadEndpoint($endpoint_class_name);
+ }
+ }
- $key = $endpoint->getRoute() . '-' . $endpoint->getMethod();
+ public function loadEndpoint(string $endpoint): void
+ {
+ try {
+ /** @var EndpointBase $endpoint */
+ $endpoint = new $endpoint();
- if (!isset($this->_endpoints[$key])) {
- $this->_endpoints[$key] = $endpoint;
- }
- } catch (Error $error) {
- // Silently ignore errors caused by invalid endpoint files,
- // but make a log entry for debugging purposes.
- ErrorHandler::logCustomError($error->getMessage());
+ $key = $endpoint->getRoute() . '-' . $endpoint->getMethod();
+
+ if (!isset($this->_endpoints[$key])) {
+ $this->_endpoints[$key] = $endpoint;
}
+ } catch (Error $error) {
+ // Silently ignore errors caused by invalid endpoint files,
+ // but make a log entry for debugging purposes.
+ ErrorHandler::logCustomError($error->getMessage());
}
}
}
diff --git a/core/classes/Events/EventHandler.php b/core/classes/Events/EventHandler.php
index 4281926182..570b176c63 100644
--- a/core/classes/Events/EventHandler.php
+++ b/core/classes/Events/EventHandler.php
@@ -1,4 +1,5 @@
make($callback), 'handle'];
+ }
}
self::$_events[$name]['listeners'][] = [
@@ -243,7 +253,7 @@ public static function getEvents(bool $showInternal = false): array
* Not used internally, currently for WebSend.
*
* @param string $event Name of event to get data for.
- * @returns array Event data.
+ * @return array Event data.
*/
public static function getEvent(string $event): array
{
diff --git a/core/classes/Events/Listener.php b/core/classes/Events/Listener.php
new file mode 100644
index 0000000000..3a2fb05de3
--- /dev/null
+++ b/core/classes/Events/Listener.php
@@ -0,0 +1,9 @@
+module = $module;
+
+ $this->moduleName = $module->getPrivateName();
+ $this->moduleDisplayName = $module->getName();
+
+ return $this;
+ }
+
+ abstract public function extend(\Illuminate\Container\Container $container): void;
+
+}
\ No newline at end of file
diff --git a/core/classes/Extend/Container.php b/core/classes/Extend/Container.php
new file mode 100644
index 0000000000..66228a6962
--- /dev/null
+++ b/core/classes/Extend/Container.php
@@ -0,0 +1,22 @@
+singletons as $class) {
+ $container->singleton($class);
+ }
+ }
+
+ public function singleton(string $class): Container {
+ $this->singletons[] = $class;
+
+ return $this;
+ }
+}
\ No newline at end of file
diff --git a/core/classes/Extend/DebugInfo.php b/core/classes/Extend/DebugInfo.php
new file mode 100644
index 0000000000..585c4dc6c3
--- /dev/null
+++ b/core/classes/Extend/DebugInfo.php
@@ -0,0 +1,20 @@
+module->setDebugInfoProvider($this->provider);
+ }
+
+ public function provide(string $provider): DebugInfo {
+ $this->provider = $provider;
+
+ return $this;
+ }
+}
\ No newline at end of file
diff --git a/core/classes/Extend/Endpoints.php b/core/classes/Extend/Endpoints.php
new file mode 100644
index 0000000000..26f5ec2048
--- /dev/null
+++ b/core/classes/Extend/Endpoints.php
@@ -0,0 +1,25 @@
+get(\Endpoints::class);
+
+ foreach ($this->endpoints as $endpoint) {
+ $endpoints->loadEndpoint($endpoint);
+ }
+ }
+
+ public function register(string $injector): self {
+ $this->endpoints[] = $injector;
+
+ return $this;
+ }
+}
\ No newline at end of file
diff --git a/core/classes/Extend/Events.php b/core/classes/Extend/Events.php
new file mode 100644
index 0000000000..3da0a0e8aa
--- /dev/null
+++ b/core/classes/Extend/Events.php
@@ -0,0 +1,39 @@
+events as $event) {
+ \EventHandler::registerEvent($event);
+ }
+
+ foreach ($this->listeners as $event => $listeners) {
+ foreach ($listeners as $listener) {
+ \EventHandler::registerListener($event, $listener, 10);
+ }
+ }
+ }
+
+ public function register(string $event): Events {
+ $this->events[] = $event;
+
+ return $this;
+ }
+
+ public function listen(string $event, string $listener): Events {
+ if (!isset($this->listeners[$event])) {
+ $this->listeners[$event] = [];
+ }
+
+ $this->listeners[$event][] = $listener;
+
+ return $this;
+ }
+}
\ No newline at end of file
diff --git a/core/classes/Extend/FrontendAssets.php b/core/classes/Extend/FrontendAssets.php
new file mode 100644
index 0000000000..cc4051e754
--- /dev/null
+++ b/core/classes/Extend/FrontendAssets.php
@@ -0,0 +1,81 @@
+get('FrontendTemplate') since these would be called before the template is intialized
+ public function extend(Container $container): void {
+ if ($container->has('FrontendAssets')) {
+ $frontendAssets = $container->get('FrontendAssets');
+ } else {
+ $frontendAssets = $container->instance('FrontendAssets', []);
+ }
+
+ // merge in global assets
+ $frontendAssets['globalJsFiles'] = array_merge($frontendAssets['globalJsFiles'] ?? [], $this->globalJsFiles);
+ $frontendAssets['globalCssFiles'] = array_merge($frontendAssets['globalCssFiles'] ?? [], $this->globalCssFiles);
+
+ // merge in page specific assets
+ foreach ($this->jsFiles as $page => $files) {
+ if (!isset($frontendAssets['jsFiles'][$page])) {
+ $frontendAssets['jsFiles'][$page] = [];
+ }
+
+ $frontendAssets['jsFiles'][$page] = array_merge($frontendAssets['jsFiles'][$page], $files);
+ }
+
+ foreach ($this->cssFiles as $page => $files) {
+ if (!isset($frontendAssets['cssFiles'][$page])) {
+ $frontendAssets['cssFiles'][$page] = [];
+ }
+
+ $frontendAssets['cssFiles'][$page] = array_merge($frontendAssets['cssFiles'][$page], $files);
+ }
+
+ $container->instance('FrontendAssets', $frontendAssets);
+ }
+
+ public function js(string $path, array $pages = []): self {
+ if (empty($pages)) {
+ $this->globalJsFiles[] = $this->trimPath($path);
+ } else {
+ foreach ($pages as $page) {
+ if (!isset($this->jsFiles[$page])) {
+ $this->jsFiles[$page] = [];
+ }
+
+ $this->jsFiles[$page][] = $this->trimPath($path);
+ }
+ }
+
+ return $this;
+ }
+
+ public function css(string $path, array $pages = []): self {
+ if (empty($pages)) {
+ $this->globalCssFiles[] = $this->trimPath($path);
+ } else {
+ foreach ($pages as $page) {
+ if (!isset($this->cssFiles[$page])) {
+ $this->cssFiles[$page] = [];
+ }
+
+ $this->cssFiles[$page][] = $this->trimPath($path);
+ }
+ }
+
+ return $this;
+ }
+
+ private function trimPath(string $path): string {
+ return substr($path, strpos($path, '/vendor'));
+ }
+}
\ No newline at end of file
diff --git a/core/classes/Extend/FrontendMiddleware.php b/core/classes/Extend/FrontendMiddleware.php
new file mode 100644
index 0000000000..524ddad10f
--- /dev/null
+++ b/core/classes/Extend/FrontendMiddleware.php
@@ -0,0 +1,29 @@
+has('FrontendMiddleware')) {
+ $middlewares = $container->get('FrontendMiddleware');
+ } else {
+ $middlewares = $container->instance('FrontendMiddleware', []);
+ }
+
+ $middlewares = array_merge($middlewares, $this->middlewares);
+
+ $container->instance('FrontendMiddleware', $middlewares);
+ }
+
+ public function register(string $middleware): self
+ {
+ $this->middlewares[] = $middleware;
+
+ return $this;
+ }
+}
\ No newline at end of file
diff --git a/core/classes/Extend/FrontendPages.php b/core/classes/Extend/FrontendPages.php
new file mode 100644
index 0000000000..e967c9ea05
--- /dev/null
+++ b/core/classes/Extend/FrontendPages.php
@@ -0,0 +1,77 @@
+get("{$this->moduleName}Language");
+
+ /** @var \Pages */
+ $pages = $container->get(\Pages::class);
+
+ /** @var \Cache */
+ $cache = $container->get(\Cache::class);
+
+ /** @var \Navigation */
+ $frontendNavigation = $container->get('FrontendNavigation');
+
+ foreach ($this->pages as $page) {
+ $path = $page['path'];
+ $name = $page['name'];
+ $title = $moduleLanguage->get($page['title_translation']);
+
+ $cache->setCache('navbar_order');
+ $order = $cache->fetch("{$name}_order", fn () => 5);
+
+ $cache->setCache('navbar_icons');
+ $icon = $cache->fetch("{$name}_icon", fn () => '');
+
+ $cache->setCache('nav_location');
+ $location = $cache->fetch("{$name}_location", fn () => 1);
+
+ switch ($location) {
+ case 1:
+ // Navbar
+ $frontendNavigation->add($name, $title, \URL::build($path), 'top', null, $order, $icon);
+ break;
+ case 2:
+ // "More" dropdown
+ $frontendNavigation->addItemToDropdown('more_dropdown', $name, $title, \URL::build($path), 'top', null, $icon, $order);
+ break;
+ case 3:
+ // Footer
+ $frontendNavigation->add($name, $title, \URL::build($path), 'footer', null, $order, $icon);
+ break;
+ }
+
+ $pages->add(
+ $this->moduleDisplayName,
+ $path,
+ $page['handler'],
+ $title,
+ $page['allowWidgets'],
+ $this->moduleName,
+ true,
+ $name,
+ );
+ }
+ }
+
+ public function register(string $path, string $name, string $titleTranslation, string $handler, bool $allowWidgets): FrontendPages {
+ $this->pages[] = [
+ 'path' => $path,
+ 'name' => $name,
+ 'title_translation' => $titleTranslation,
+ 'handler' => $handler,
+ 'allowWidgets' => $allowWidgets
+ ];
+
+ return $this;
+ }
+}
\ No newline at end of file
diff --git a/core/classes/Extend/GroupSync.php b/core/classes/Extend/GroupSync.php
new file mode 100644
index 0000000000..6b391a5b08
--- /dev/null
+++ b/core/classes/Extend/GroupSync.php
@@ -0,0 +1,22 @@
+injectors as $injector) {
+ \GroupSyncManager::getInstance()->registerInjector(new $injector);
+ }
+ }
+
+ public function register(string $injector): self {
+ $this->injectors[] = $injector;
+
+ return $this;
+ }
+}
\ No newline at end of file
diff --git a/core/classes/Extend/Integrations.php b/core/classes/Extend/Integrations.php
new file mode 100644
index 0000000000..3491d7a672
--- /dev/null
+++ b/core/classes/Extend/Integrations.php
@@ -0,0 +1,22 @@
+integrations as $integration) {
+ \Integrations::getInstance()->registerIntegration($container->make($integration));
+ }
+ }
+
+ public function register(string $injector): self {
+ $this->integrations[] = $injector;
+
+ return $this;
+ }
+}
\ No newline at end of file
diff --git a/core/classes/Extend/Language.php b/core/classes/Extend/Language.php
new file mode 100644
index 0000000000..2d1abfbeb8
--- /dev/null
+++ b/core/classes/Extend/Language.php
@@ -0,0 +1,19 @@
+path = $path;
+ }
+
+ public function extend(Container $container): void {
+ $containerKey = "{$this->moduleName}Language";
+
+ $container->bind($containerKey, fn () => new \Language($this->path));
+ }
+}
\ No newline at end of file
diff --git a/core/classes/Extend/ModuleLifecycle.php b/core/classes/Extend/ModuleLifecycle.php
new file mode 100644
index 0000000000..acf0f0ba2e
--- /dev/null
+++ b/core/classes/Extend/ModuleLifecycle.php
@@ -0,0 +1,43 @@
+module->setOnInstall($this->onInstall);
+ $this->module->setOnEnable($this->onEnable);
+ $this->module->setOnDisable($this->onDisable);
+ $this->module->setOnUninstall($this->onUninstall);
+ }
+
+ public function onInstall(string $hook): ModuleLifecycle {
+ $this->onInstall[] = $hook;
+
+ return $this;
+ }
+
+ public function onEnable(string $hook): ModuleLifecycle {
+ $this->onEnable[] = $hook;
+
+ return $this;
+ }
+
+ public function onDisable(string $hook): ModuleLifecycle {
+ $this->onDisable[] = $hook;
+
+ return $this;
+ }
+
+ public function onUninstall(string $hook): ModuleLifecycle {
+ $this->onUninstall[] = $hook;
+
+ return $this;
+ }
+}
\ No newline at end of file
diff --git a/core/classes/Extend/PanelPages.php b/core/classes/Extend/PanelPages.php
new file mode 100644
index 0000000000..4dc2650743
--- /dev/null
+++ b/core/classes/Extend/PanelPages.php
@@ -0,0 +1,117 @@
+get("{$this->moduleName}Language");
+
+ /** @var \Pages */
+ $pages = $container->get(\Pages::class);
+
+ /** @var \User */
+ $user = $container->get(\User::class);
+
+ /** @var \Cache */
+ $cache = $container->get(\Cache::class);
+ $cache->setCache('panel_sidebar');
+
+ /** @var \Navigation */
+ $panelNavigation = $container->get('PanelNavigation');
+
+ if (!empty($this->pages)) {
+ $moduleSidebarOrder = array_reduce(array_filter($cache->retrieveAll(), function ($item) {
+ return str_ends_with($item, '_order');
+ }, ARRAY_FILTER_USE_KEY), function ($carry, $item) {
+ return $item > $carry ? $item : $carry;
+ }, 0) + 1;
+ $panelNavigation->add("{$this->moduleName}_divider", mb_strtoupper($this->moduleDisplayName), 'divider', 'top', null, $moduleSidebarOrder);
+
+ $lastSubPageOrder = $moduleSidebarOrder;
+
+ foreach ($this->pages as $page) {
+ $path = ltrim($page['path'], '/');
+ $path = "/panel/{$path}";
+
+ $order = $lastSubPageOrder + 0.1;
+ $lastSubPageOrder = $order;
+
+ $title = $moduleLanguage->get($page['title_translation']);
+
+ if ($user->hasPermission($page['permission'])) {
+ $icon = "";
+ $panelNavigation->add($page['name'], $title, \URL::build($path), 'top', null, $order, $icon);
+ }
+
+ $this->registerInternalPage($pages, $path, $page['handler'], $title);
+ }
+ }
+
+ if (!empty($this->dropdownPages)) {
+ foreach ($this->dropdownPages as $dropdownName => $dropdownPages) {
+ foreach ($dropdownPages as $page) {
+ $path = ltrim($page['path'], '/');
+ $path = "/panel/{$path}";
+
+ $title = $moduleLanguage->get($page['title_translation']);
+
+ if ($user->hasPermission($page['permission'])) {
+ $icon = "";
+ $panelNavigation->addItemToDropdown($dropdownName, $page['name'], $title, \URL::build($path), 'top', null, $icon);
+ }
+
+ $this->registerInternalPage($pages, $path, $page['handler'], $title);
+ }
+ }
+ }
+ }
+
+ public function register(string $path, string $name, string $titleTranslation, string $handler, string $permission, string $icon): PanelPages {
+ $this->pages[] = [
+ 'path' => $path,
+ 'name' => $name,
+ 'title_translation' => $titleTranslation,
+ 'handler' => $handler,
+ 'permission' => $permission,
+ 'icon' => $icon,
+ ];
+
+ return $this;
+ }
+
+ public function registerInDropdown(string $dropdownName, string $path, string $name, string $titleTranslation, string $handler, string $permission, string $icon): PanelPages {
+ if (!isset($this->dropdownPages[$dropdownName])) {
+ $this->dropdownPages[$dropdownName] = [];
+ }
+
+ $this->dropdownPages[$dropdownName][] = [
+ 'path' => $path,
+ 'name' => $name,
+ 'title_translation' => $titleTranslation,
+ 'handler' => $handler,
+ 'permission' => $permission,
+ 'icon' => $icon,
+ ];
+
+ return $this;
+ }
+
+ private function registerInternalPage(\Pages $pages, string $path, string $handler, string $title): void {
+ $pages->add(
+ $this->moduleDisplayName,
+ $path,
+ $handler,
+ $title,
+ false,
+ $this->moduleName,
+ true,
+ );
+ }
+}
\ No newline at end of file
diff --git a/core/classes/Extend/Permissions.php b/core/classes/Extend/Permissions.php
new file mode 100644
index 0000000000..48937f32b1
--- /dev/null
+++ b/core/classes/Extend/Permissions.php
@@ -0,0 +1,29 @@
+get(\Language::class);
+ $moduleLanguage = $container->get("{$this->moduleName}Language");
+
+ if (isset($this->permissions['staffcp'])) {
+ foreach ($this->permissions['staffcp'] as $permission => $name) {
+ \PermissionHandler::registerPermissions($coreLanguage->get('moderator', 'staff_cp'), [
+ $permission => $this->moduleDisplayName . ' » ' . $moduleLanguage->get($name)
+ ]);
+ }
+ }
+ }
+
+ public function register(array $permissions): Permissions {
+ $this->permissions = $permissions;
+
+ return $this;
+ }
+}
\ No newline at end of file
diff --git a/core/classes/Extend/Queries.php b/core/classes/Extend/Queries.php
new file mode 100644
index 0000000000..e09daf4e10
--- /dev/null
+++ b/core/classes/Extend/Queries.php
@@ -0,0 +1,40 @@
+get(\Pages::class);
+
+ foreach ($this->pages as $page) {
+ $path = ltrim($page['path'], '/');
+ $path = "/queries/{$path}";
+ $path = rtrim($path, '/');
+
+ $pages->add(
+ $this->moduleDisplayName,
+ $path,
+ $page['handler'],
+ '',
+ false,
+ $this->moduleName,
+ true,
+ );
+ }
+ }
+
+ public function register(string $path, string $handler): Queries {
+ $this->pages[] = [
+ 'path' => $path,
+ 'handler' => $handler,
+ ];
+
+ return $this;
+ }
+}
\ No newline at end of file
diff --git a/core/classes/Extend/Sitemap.php b/core/classes/Extend/Sitemap.php
new file mode 100644
index 0000000000..c7058f14ee
--- /dev/null
+++ b/core/classes/Extend/Sitemap.php
@@ -0,0 +1,30 @@
+get(\Pages::class);
+
+ foreach ($this->paths as $path) {
+ $pages->registerSitemapMethod(static function (\SitemapPHP\Sitemap $sitemap) use ($path) {
+ $sitemap->addItem(\URL::build($path['path']), $path['priority']);
+ });
+ }
+ }
+
+ public function path(string $path, float $priority = \SitemapPHP\Sitemap::DEFAULT_PRIORITY): self {
+ $this->paths[] = [
+ 'path' => $path,
+ 'priority' => $priority
+ ];
+
+ return $this;
+ }
+}
\ No newline at end of file
diff --git a/core/classes/Extend/Widgets.php b/core/classes/Extend/Widgets.php
new file mode 100644
index 0000000000..2563d9ceb9
--- /dev/null
+++ b/core/classes/Extend/Widgets.php
@@ -0,0 +1,33 @@
+get(\Pages::class);
+
+ // Skip initialization if we don't need to display any widgets
+ if (!$pages->getActivePage()['widgets'] && (defined('PANEL_PAGE') && !str_contains(PANEL_PAGE, 'widget'))) {
+ return;
+ }
+
+ /** @var \Widgets */
+ $widgets = $container->get(\Widgets::class);
+
+ foreach ($this->widgets as $widget) {
+ $widgets->add($container->make($widget));
+ }
+ }
+
+ public function register(string $widget): self {
+ $this->widgets[] = $widget;
+
+ return $this;
+ }
+}
\ No newline at end of file
diff --git a/core/classes/Integrations/HasIntegrationSettings.php b/core/classes/Integrations/HasIntegrationSettings.php
new file mode 100644
index 0000000000..d81c63a67b
--- /dev/null
+++ b/core/classes/Integrations/HasIntegrationSettings.php
@@ -0,0 +1,10 @@
+ $file) {
+ if (is_int($href)) {
+ $href = $file;
+ $file = [];
+ }
+
$this->_css[] = '
$file) {
+ if (is_int($href)) {
+ $href = $file;
+ $file = [];
+ }
+
$this->_js[] = '
-
-