Initial baseline: LaraBlog core with plugin commerce surface.

Captures the current working tree after theme slots, ArticleAccess, and the payment / paid-content plugins so subsequent work has a reviewable git history.
This commit is contained in:
ak
2026-08-12 01:15:38 +08:00
commit 263b98b218
337 changed files with 31393 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Domain\Plugin;
class Hook
{
/** @var array<string, list<callable>> */
protected static array $listeners = [];
public static function listen(string $event, callable $listener): void
{
static::$listeners[$event][] = $listener;
}
public static function dispatch(string $event, mixed ...$payload): void
{
foreach (static::$listeners[$event] ?? [] as $listener) {
$listener(...$payload);
}
}
/**
* Collect string fragments from listeners (for theme injection points).
*/
public static function gather(string $event, string $initial = '', mixed ...$payload): string
{
$buffer = $initial;
foreach (static::$listeners[$event] ?? [] as $listener) {
$result = $listener($buffer, ...$payload);
if (is_string($result)) {
$buffer = $result;
}
}
return $buffer;
}
/**
* Merge array fragments from listeners (for Filament schema/columns/actions).
*
* @param array<int|string, mixed> $initial
* @return array<int|string, mixed>
*/
public static function collect(string $event, array $initial = [], mixed ...$payload): array
{
$items = $initial;
foreach (static::$listeners[$event] ?? [] as $listener) {
$result = $listener($items, ...$payload);
if (! is_array($result)) {
continue;
}
foreach ($result as $key => $value) {
if (is_int($key)) {
$items[] = $value;
} else {
$items[$key] = $value;
}
}
}
return $items;
}
/**
* Pipe a value through listeners (each may replace it).
*/
public static function filter(string $event, mixed $value, mixed ...$payload): mixed
{
foreach (static::$listeners[$event] ?? [] as $listener) {
$value = $listener($value, ...$payload);
}
return $value;
}
/**
* Registered listeners for an event, for callers that need to fold results
* themselves instead of letting each listener replace the value outright.
*
* @return list<callable>
*/
public static function listeners(string $event): array
{
return static::$listeners[$event] ?? [];
}
public static function flush(): void
{
static::$listeners = [];
}
}
+243
View File
@@ -0,0 +1,243 @@
<?php
declare(strict_types=1);
namespace App\Domain\Plugin;
use App\Models\Plugin;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\ServiceProvider;
use InvalidArgumentException;
use RuntimeException;
class PluginManager
{
/** @var array<string, array<string, mixed>> */
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<string>
*/
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<string>
*/
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
{
$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']);
$path = realpath(rtrim((string) $manifest['path'], '/').'/'.$docs);
if ($root === false || $path === false || ! is_file($path)) {
return null;
}
// Never read outside the plugin directory, even via symlinks.
if ($path !== $root && ! str_starts_with($path, $root.DIRECTORY_SEPARATOR)) {
return null;
}
return $path;
}
public function readDocs(string $name): ?string
{
$path = $this->docsPath($name);
return $path !== null ? (string) file_get_contents($path) : null;
}
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;
}
}