> */ protected array $discovered = []; public function discover(): Collection { $pluginPath = config('larablog.plugin_path', base_path('plugins')); if (! is_dir($pluginPath)) { return collect(); } $plugins = collect(); foreach (File::directories($pluginPath) as $vendorDirectory) { foreach (File::directories($vendorDirectory) as $pluginDirectory) { $manifestPath = $pluginDirectory.'/plugin.json'; if (! is_file($manifestPath)) { continue; } $manifest = json_decode((string) file_get_contents($manifestPath), true); if (! is_array($manifest)) { continue; } $name = (string) ($manifest['name'] ?? basename($pluginDirectory)); $relativePath = str_replace(base_path().'/', '', $pluginDirectory); $plugins->put($name, array_merge($manifest, [ 'name' => $name, 'path' => $pluginDirectory, 'relative_path' => $relativePath, 'provider' => $manifest['provider'] ?? null, 'requires' => array_values(array_filter( array_map('strval', (array) ($manifest['requires'] ?? [])), fn (string $item): bool => $item !== '', )), 'optional' => array_values(array_filter( array_map('strval', (array) ($manifest['optional'] ?? [])), fn (string $item): bool => $item !== '', )), 'docs' => (string) ($manifest['docs'] ?? 'README.md'), ])); } } $this->discovered = $plugins->all(); return $plugins; } public function syncDiscoveredPlugins(): Collection { $discovered = $this->discover(); foreach ($discovered as $name => $manifest) { Plugin::query()->updateOrCreate( ['name' => $name], [ 'version' => (string) ($manifest['version'] ?? '1.0.0'), 'path' => (string) ($manifest['relative_path'] ?? $manifest['path']), ], ); } return $discovered; } public function isEnabled(string $name): bool { return Plugin::query() ->where('name', $name) ->where('enabled', true) ->exists(); } /** * @return list */ public function missingRequires(string $name): array { $manifest = $this->discover()->get($name); if ($manifest === null) { throw new InvalidArgumentException("Plugin [{$name}] is not installed."); } $missing = []; foreach ((array) ($manifest['requires'] ?? []) as $required) { if (! $this->isEnabled((string) $required)) { $missing[] = (string) $required; } } return $missing; } public function enable(string $name): Plugin { $missing = $this->missingRequires($name); if ($missing !== []) { throw new RuntimeException( __('admin.messages.plugin_requires', [ 'plugin' => $name, 'requires' => implode(', ', $missing), ]) ); } $plugin = $this->findPluginRecord($name); $plugin->update(['enabled' => true]); return $plugin->refresh(); } public function disable(string $name): Plugin { $dependents = $this->enabledDependentsOf($name); if ($dependents !== []) { throw new RuntimeException( __('admin.messages.plugin_required_by', [ 'plugin' => $name, 'dependents' => implode(', ', $dependents), ]) ); } $plugin = $this->findPluginRecord($name); $plugin->update(['enabled' => false]); return $plugin->refresh(); } /** * @return list */ public function enabledDependentsOf(string $name): array { $dependents = []; foreach ($this->discover() as $candidate => $manifest) { if ($candidate === $name || ! $this->isEnabled((string) $candidate)) { continue; } $requires = array_map('strval', (array) ($manifest['requires'] ?? [])); if (in_array($name, $requires, true)) { $dependents[] = (string) $candidate; } } return $dependents; } public function docsPath(string $name, ?string $locale = null): ?string { $manifest = $this->discover()->get($name); if ($manifest === null) { return null; } $docs = trim((string) ($manifest['docs'] ?? 'README.md')); if ($docs === '' || str_starts_with($docs, '/') || preg_match('#(^|[\\\\/])\.\.([\\\\/]|$)#', $docs) === 1) { return null; } $root = realpath((string) $manifest['path']); if ($root === false) { return null; } foreach ($this->docsCandidates($docs, $locale ?? app()->getLocale()) as $relative) { if (str_starts_with($relative, '/') || preg_match('#(^|[\\\\/])\.\.([\\\\/]|$)#', $relative) === 1) { continue; } $path = realpath($root.DIRECTORY_SEPARATOR.str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relative)); if ($path === false || ! is_file($path)) { continue; } // Never read outside the plugin directory, even via symlinks. if ($path !== $root && ! str_starts_with($path, $root.DIRECTORY_SEPARATOR)) { continue; } return $path; } return null; } public function readDocs(string $name, ?string $locale = null): ?string { $path = $this->docsPath($name, $locale); if ($path === null) { return null; } $contents = (string) file_get_contents($path); return trim($contents) === '' ? null : $contents; } public function readDocsHtml(string $name, ?string $locale = null): ?string { $markdown = $this->readDocs($name, $locale); if ($markdown === null) { return null; } $html = app(ContentRenderer::class)->markdownToHtml($markdown); return Purifier::clean($html, 'article'); } /** * Prefer README.{locale}.md, then README.{lang}.md, then the manifest docs file. * * @return list */ protected function docsCandidates(string $docs, string $locale): array { $docs = str_replace('\\', '/', $docs); $directory = dirname($docs); $basename = basename($docs); $extension = pathinfo($basename, PATHINFO_EXTENSION); $stem = $extension !== '' ? substr($basename, 0, -strlen($extension) - 1) : $basename; $suffix = $extension !== '' ? '.'.$extension : ''; $prefix = ($directory === '.' || $directory === '') ? '' : $directory.'/'; $names = []; $normalized = str_replace('-', '_', trim($locale)); if ($normalized !== '' && preg_match('/^[A-Za-z0-9_]+$/', $normalized) === 1) { $names[] = $stem.'.'.$normalized.$suffix; if (str_contains($normalized, '_')) { $names[] = $stem.'.'.explode('_', $normalized, 2)[0].$suffix; } } $names[] = $basename; $candidates = []; foreach ($names as $name) { $relative = $prefix.$name; if (! in_array($relative, $candidates, true)) { $candidates[] = $relative; } } return $candidates; } public function registerEnabledProviders(): void { $discovered = $this->discover(); Plugin::query() ->where('enabled', true) ->get() ->each(function (Plugin $plugin) use ($discovered): void { $manifest = $discovered->get($plugin->name); if ($manifest === null) { return; } $providerClass = $manifest['provider'] ?? null; if (! is_string($providerClass) || ! class_exists($providerClass)) { return; } if (! is_subclass_of($providerClass, ServiceProvider::class)) { return; } app()->register($providerClass); }); } protected function findPluginRecord(string $name): Plugin { $plugin = Plugin::query()->where('name', $name)->first(); if ($plugin === null) { throw new InvalidArgumentException("Plugin [{$name}] is not installed."); } return $plugin; } }