wip: article AI polish, category SEO fields, cover generator, membership plan seeder
CI / PHPUnit (PHP 8.3) (push) Failing after 4s
CI / PHPUnit (PHP 8.2) (push) Failing after 1m9s
CI / Deploy (manual gate) (push) Skipped

This commit is contained in:
2026-09-07 18:48:37 +00:00
parent 263b98b218
commit 3cec4c5e18
121 changed files with 4701 additions and 313 deletions
+79 -11
View File
@@ -4,11 +4,13 @@ declare(strict_types=1);
namespace App\Domain\Plugin;
use App\Domain\Blog\ContentRenderer;
use App\Models\Plugin;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\ServiceProvider;
use InvalidArgumentException;
use Mews\Purifier\Facades\Purifier;
use RuntimeException;
class PluginManager
@@ -168,7 +170,7 @@ class PluginManager
return $dependents;
}
public function docsPath(string $name): ?string
public function docsPath(string $name, ?string $locale = null): ?string
{
$manifest = $this->discover()->get($name);
if ($manifest === null) {
@@ -181,25 +183,91 @@ class PluginManager
}
$root = realpath((string) $manifest['path']);
$path = realpath(rtrim((string) $manifest['path'], '/').'/'.$docs);
if ($root === false || $path === false || ! is_file($path)) {
if ($root === false) {
return null;
}
// Never read outside the plugin directory, even via symlinks.
if ($path !== $root && ! str_starts_with($path, $root.DIRECTORY_SEPARATOR)) {
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 $path;
return null;
}
public function readDocs(string $name): ?string
public function readDocs(string $name, ?string $locale = null): ?string
{
$path = $this->docsPath($name);
$path = $this->docsPath($name, $locale);
if ($path === null) {
return null;
}
return $path !== null ? (string) file_get_contents($path) : 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<string>
*/
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