diff --git a/config/sidecar.php b/config/sidecar.php new file mode 100644 index 00000000000..0395eadfadc --- /dev/null +++ b/config/sidecar.php @@ -0,0 +1,36 @@ + [ + + // 'docs' => [ + // 'driver' => 'laradocs', + // 'directory' => base_path('docs'), + // // 'title' => 'Documentation', + // // 'blueprint' => 'custom_docs', + // ], + + // 'docs' => [ + // 'driver' => 'jigsaw', + // 'directory' => base_path('source/docs'), + // // 'navigation' => base_path('navigation.php'), + // // 'url_prefix' => 'docs', + // ], + + ], + +]; diff --git a/resources/js/components/fieldtypes/TextareaFieldtype.vue b/resources/js/components/fieldtypes/TextareaFieldtype.vue index f3b28678e83..59b2f0b3663 100644 --- a/resources/js/components/fieldtypes/TextareaFieldtype.vue +++ b/resources/js/components/fieldtypes/TextareaFieldtype.vue @@ -8,6 +8,7 @@ :placeholder="__(config.placeholder)" :model-value="value" :dir="contentDirection" + :rows="config.rows || 3" @blur="$emit('blur')" @focus="$emit('focus')" @update:model-value="updateDebounced" diff --git a/src/Console/Commands/SidecarInstall.php b/src/Console/Commands/SidecarInstall.php new file mode 100644 index 00000000000..60d0289e84b --- /dev/null +++ b/src/Console/Commands/SidecarInstall.php @@ -0,0 +1,184 @@ +argument('driver') ?? $this->resolveDriver(); + + if (! $driver) { + return; + } + + $package = $this->packageForDriver($driver); + + if ($package && ! Composer::isInstalled($package)) { + spin( + fn () => Composer::withoutQueue()->throwOnFailure()->require($package), + "Installing {$package}..." + ); + + $this->checkLine("Installed {$package}"); + } elseif ($package) { + $this->checkLine("{$package} is already installed"); + } + + $this->writeConfig($driver); + + info('Sidecar is ready. Your adapted collections will appear in the Control Panel.'); + } + + protected function resolveDriver(): ?string + { + $detected = $this->detectDrivers(); + + if ($detected->isEmpty()) { + $available = collect(Sidecar::registeredDrivers()); + + if ($available->isEmpty() && Sidecar::packages()->isEmpty()) { + error('No Sidecar drivers are available. Install a driver package first (e.g. composer require statamic/sidecar-laradocs), then re-run this command.'); + + return null; + } + + $choices = Sidecar::packages() + ->mapWithKeys(fn ($package, $ssg) => [Str::afterLast($package, '/sidecar-') => "{$package} (for {$ssg})"]) + ->merge($available->mapWithKeys(fn ($driver) => [$driver => $driver])) + ->all(); + + return select('Which Sidecar driver would you like to install?', $choices); + } + + if ($detected->count() === 1) { + $driver = $detected->keys()->first(); + + if (confirm("Detected {$detected->first()}. Install the [{$driver}] Sidecar driver?")) { + return $driver; + } + + return null; + } + + return select( + 'Multiple compatible packages detected. Which Sidecar driver would you like to install?', + $detected->mapWithKeys(fn ($ssg, $driver) => [$driver => "{$driver} ({$ssg})"])->all() + ); + } + + protected function detectDrivers() + { + return Sidecar::packages() + ->filter(fn ($package, $ssg) => Composer::isInstalled($ssg)) + ->mapWithKeys(fn ($package, $ssg) => [Str::afterLast($package, '/sidecar-') => $ssg]); + } + + protected function packageForDriver(string $driver): ?string + { + $package = Sidecar::packages()->first( + fn ($package) => Str::endsWith($package, '/sidecar-'.$driver) || Str::endsWith($package, '/'.$driver) + ); + + if ($package) { + return $package; + } + + // Driver packages register via Sidecar::pair(), so when the package + // isn't installed yet we fall back to the first-party naming convention. + if (! Sidecar::hasDriver($driver)) { + return 'statamic/sidecar-'.$driver; + } + + return null; + } + + protected function writeConfig(string $driver): void + { + $path = config_path('statamic/sidecar.php'); + + if (! File::exists($path)) { + File::ensureDirectoryExists(dirname($path)); + File::copy(__DIR__.'/../../../config/sidecar.php', $path); + $this->checkLine('Published config/statamic/sidecar.php'); + } + + $config = require $path; + + if (isset($config['collections'][$this->defaultHandleFor($driver)])) { + $this->checkLine('Sidecar collection config already present'); + + return; + } + + if (! confirm('Would you like to add a default collection config for this driver?')) { + return; + } + + $handle = $this->defaultHandleFor($driver); + $directory = $this->defaultDirectoryFor($driver); + + $stub = File::get($path); + + $entry = << [ + 'driver' => '{$driver}', + 'directory' => {$directory}, + ], +PHP; + + if (Str::contains($stub, "'collections' => [")) { + $stub = Str::replaceFirst( + "'collections' => [", + "'collections' => [".$entry, + $stub + ); + + // Remove the example comment block if present to keep the file tidy. + $stub = preg_replace('/\n\s*\/\/ \'docs\' => \[.*?\],\n/s', "\n", $stub); + + File::put($path, $stub); + $this->checkLine("Added [{$handle}] collection to config/statamic/sidecar.php"); + } else { + error('Could not automatically update config/statamic/sidecar.php. Please add the collection manually.'); + } + } + + protected function defaultHandleFor(string $driver): string + { + return match ($driver) { + 'laradocs', 'jigsaw' => 'docs', + default => $driver, + }; + } + + protected function defaultDirectoryFor(string $driver): string + { + return match ($driver) { + 'laradocs' => "base_path('docs')", + 'jigsaw' => "base_path('source/docs')", + default => "base_path('{$driver}')", + }; + } +} diff --git a/src/Contracts/Entries/CollectionRepository.php b/src/Contracts/Entries/CollectionRepository.php index 992afe08d5b..5b3da611570 100644 --- a/src/Contracts/Entries/CollectionRepository.php +++ b/src/Contracts/Entries/CollectionRepository.php @@ -23,4 +23,6 @@ public function handles(): IlluminateCollection; public function handleExists(string $handle): bool; public function whereStructured(): IlluminateCollection; + + public function register(Collection $collection): void; } diff --git a/src/Entries/Collection.php b/src/Entries/Collection.php index 5b02d69a8f8..25767e92859 100644 --- a/src/Entries/Collection.php +++ b/src/Entries/Collection.php @@ -24,6 +24,7 @@ use Statamic\Facades\Blueprint; use Statamic\Facades\Entry; use Statamic\Facades\File; +use Statamic\Facades\Path; use Statamic\Facades\Search; use Statamic\Facades\Site; use Statamic\Facades\Stache; @@ -69,6 +70,8 @@ class Collection implements Arrayable, ArrayAccess, AugmentableContract, Contain protected $previewTargets = []; protected $autosave; protected $withEvents = true; + protected $directory; + protected $entryBlueprintFallback; protected $entryClass; @@ -90,6 +93,9 @@ public function handle($handle = null) $this->handle = $handle; + // Re-register once we know the handle, in case directory() was set first. + $this->registerCustomDirectory($this->directory); + return $this; } @@ -117,6 +123,25 @@ public function route($site) return $this->routes()->get($site); } + /** + * Whether Live Preview should be available for this collection. + * + * Collections without a route (e.g. Sidecar / headless) can still enable + * Live Preview by defining custom preview targets. + */ + public function hasLivePreview($site = null): bool + { + if ($site && $this->route($site)) { + return true; + } + + if (! $site && $this->routes()->filter()->isNotEmpty()) { + return true; + } + + return ! empty($this->previewTargets); + } + public function requiresSlugs($require = null) { return $this->fluentlyGetOrSet('requiresSlugs')->args(func_get_args()); @@ -127,6 +152,45 @@ public function entryClass($class = null) return $this->fluentlyGetOrSet('entryClass')->args(func_get_args()); } + public function directory($directory = null) + { + return $this + ->fluentlyGetOrSet('directory') + ->setter(function ($directory) { + $this->registerCustomDirectory($directory); + + return $directory; + }) + ->args(func_get_args()); + } + + public function resolvedDirectory(): string + { + if ($this->directory) { + return Path::tidy( + Path::isAbsolute($this->directory) + ? $this->directory + : base_path($this->directory) + ); + } + + return Path::tidy(Stache::store('entries')->directory().$this->handle); + } + + protected function registerCustomDirectory(?string $directory): void + { + if (! $this->handle) { + return; + } + + // Entries store may be absent in partial Stache setups (e.g. unit tests). + if (! $store = Stache::store('entries')) { + return; + } + + $store->setCustomDirectory($this->handle, $directory); + } + public function titleFormats($formats = null) { return $this @@ -375,10 +439,25 @@ private function getBaseEntryBlueprint($blueprint) }); } + public function entryBlueprintFallback($fallback = null) + { + return $this->fluentlyGetOrSet('entryBlueprintFallback')->args(func_get_args()); + } + public function fallbackEntryBlueprint() { - $blueprint = (clone Blueprint::find('default')) - ->setHandle(Str::singular($this->handle())) + if ($this->entryBlueprintFallback instanceof \Closure) { + $blueprint = ($this->entryBlueprintFallback)(); + } elseif ($this->entryBlueprintFallback instanceof \Statamic\Fields\Blueprint) { + $blueprint = clone $this->entryBlueprintFallback; + } else { + // Preserve exact core behavior when no Sidecar/custom fallback is set. + $blueprint = (clone Blueprint::find('default')) + ->setHandle(Str::singular($this->handle())); + } + + $blueprint + ->setHandle($blueprint->handle() ?? Str::singular($this->handle())) ->setNamespace('collections.'.$this->handle()); $contents = $blueprint->contents(); @@ -596,6 +675,7 @@ public function fileData() 'title_format' => $this->titleFormats, 'autosave' => $this->autosave, 'entry_class' => $this->entryClass, + 'directory' => $this->directory, ]; $array = Arr::except($formerlyToArray, [ @@ -935,7 +1015,11 @@ private function previewTargetsForFile() public function deleteFile() { File::delete($this->path()); - File::delete(dirname($this->path()).'/'.$this->handle); + + // Don't delete a custom entry directory — it may belong to another system. + if (! $this->directory) { + File::delete(dirname($this->path()).'/'.$this->handle); + } } public function entryBlueprintCommandPaletteLinks() diff --git a/src/Entries/Entry.php b/src/Entries/Entry.php index 1bf3e4c0f84..c0b7a1a1aa8 100644 --- a/src/Entries/Entry.php +++ b/src/Entries/Entry.php @@ -43,7 +43,6 @@ use Statamic\Facades\Blink; use Statamic\Facades\Collection; use Statamic\Facades\Site; -use Statamic\Facades\Stache; use Statamic\GraphQL\ResolvesValues; use Statamic\Revisions\Revisable; use Statamic\Routing\Routable; @@ -355,7 +354,7 @@ public function restoreRevisionUrl() public function livePreviewUrl() { - return $this->collection()->route($this->locale()) + return $this->collection()->hasLivePreview($this->locale()) ? $this->cpUrl('collections.entries.preview.edit') : null; } @@ -545,9 +544,8 @@ public function buildPath() $prefix = $this->date->copy()->setTimezone(config('app.timezone'))->format($format).'.'; } - return vsprintf('%s/%s/%s%s%s.%s', [ - rtrim(Stache::store('entries')->directory(), '/'), - $this->collectionHandle(), + return vsprintf('%s/%s%s%s.%s', [ + rtrim($this->collection()->resolvedDirectory(), '/'), Site::multiEnabled() ? $this->locale().'/' : '', $prefix, $this->slug() ?? $this->id(), @@ -1015,13 +1013,15 @@ public function uri() return Blink::store('entry-uris')->get($this->id()); } - if (! $this->route()) { - return null; - } + $uri = null; - $uri = ($structure = $this->structure()) - ? $structure->entryUri($this) - : $this->routableUri(); + if ($this->route()) { + $uri = ($structure = $this->structure()) + ? $structure->entryUri($this) + : $this->routableUri(); + } elseif ($sidecarUri = $this->sidecarUri()) { + $uri = $sidecarUri; + } if ($uri && $this->id()) { Blink::store('entry-uris')->put($this->id(), $uri); @@ -1030,6 +1030,22 @@ public function uri() return $uri; } + /** + * Public URI from a Sidecar driver when the collection has no Statamic route. + */ + protected function sidecarUri(): ?string + { + $handle = $this->collectionHandle(); + + if (! $handle || ! Facades\Sidecar::manages($handle)) { + return null; + } + + $url = Facades\Sidecar::driver($handle)->previewUrl($this); + + return $url ? Facades\URL::makeRelative($url) : null; + } + public function fileExtension() { return 'md'; diff --git a/src/Facades/Collection.php b/src/Facades/Collection.php index 9d499b99cc2..46df0991a19 100644 --- a/src/Facades/Collection.php +++ b/src/Facades/Collection.php @@ -16,6 +16,7 @@ * @method static bool handleExists(string $handle) * @method static void save(\Statamic\Entries\Collection $collection) * @method static void delete(\Statamic\Entries\Collection $collection) + * @method static void register(\Statamic\Entries\Collection $collection) * @method static \Illuminate\Support\Collection whereStructured() * @method static \Illuminate\Support\Collection additionalPreviewTargets(string $handle) * @method static void computed(string|array $scopes, string|array $field, ?\Closure $callback = null) diff --git a/src/Facades/Sidecar.php b/src/Facades/Sidecar.php new file mode 100644 index 00000000000..d71e79d5ed2 --- /dev/null +++ b/src/Facades/Sidecar.php @@ -0,0 +1,31 @@ + 'integer', 'width' => 50, ], + 'rows' => [ + 'display' => ['Rows'], + 'instructions' => __('statamic::fieldtypes.text.config.rows'), + 'type' => 'integer', + 'width' => 50, + 'default' => 3, + ], ], ], [ diff --git a/src/Http/Controllers/CP/Collections/EntriesController.php b/src/Http/Controllers/CP/Collections/EntriesController.php index acd8b573d60..078549aa775 100644 --- a/src/Http/Controllers/CP/Collections/EntriesController.php +++ b/src/Http/Controllers/CP/Collections/EntriesController.php @@ -343,7 +343,7 @@ public function create(Request $request, $collection, $site) 'exists' => false, 'published' => false, 'url' => cp_route('collections.entries.create', [$collection->handle(), $handle, 'blueprint' => $blueprint->handle()]), - 'livePreviewUrl' => $collection->route($handle) ? cp_route('collections.entries.preview.create', [$collection->handle(), $handle]) : null, + 'livePreviewUrl' => $collection->hasLivePreview($handle) ? cp_route('collections.entries.preview.create', [$collection->handle(), $handle]) : null, ]; })->values()->all(), 'revisionsEnabled' => $collection->revisionsEnabled(), diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index 4cdd2a79997..a8093fa6259 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -36,7 +36,7 @@ class AppServiceProvider extends ServiceProvider protected $configFiles = [ 'antlers', 'api', 'assets', 'autosave', 'cp', 'editions', 'forms', 'git', 'graphql', 'live_preview', 'markdown', 'oauth', 'protect', 'revisions', - 'routes', 'search', 'static_caching', 'stache', 'system', 'templates', 'users', 'webauthn', + 'routes', 'search', 'sidecar', 'static_caching', 'stache', 'system', 'templates', 'users', 'webauthn', ]; public function boot() diff --git a/src/Providers/ConsoleServiceProvider.php b/src/Providers/ConsoleServiceProvider.php index 8d058c405ed..d56b7081a29 100644 --- a/src/Providers/ConsoleServiceProvider.php +++ b/src/Providers/ConsoleServiceProvider.php @@ -20,6 +20,7 @@ class ConsoleServiceProvider extends ServiceProvider Commands\InstallCollaboration::class, Commands\InstallEloquentDriver::class, Commands\InstallSsg::class, + Commands\SidecarInstall::class, Commands\FlatCamp::class, Commands\LicenseSet::class, Commands\MakeAction::class, diff --git a/src/Providers/StatamicServiceProvider.php b/src/Providers/StatamicServiceProvider.php index 5dedf9dd135..977d3e14805 100644 --- a/src/Providers/StatamicServiceProvider.php +++ b/src/Providers/StatamicServiceProvider.php @@ -26,6 +26,7 @@ class StatamicServiceProvider extends AggregateServiceProvider GlideServiceProvider::class, MarkdownServiceProvider::class, \Statamic\Search\ServiceProvider::class, + \Statamic\Sidecar\ServiceProvider::class, \Statamic\StaticCaching\ServiceProvider::class, CpServiceProvider::class, RouteServiceProvider::class, diff --git a/src/Sidecar/Driver.php b/src/Sidecar/Driver.php new file mode 100644 index 00000000000..f04b03283f3 --- /dev/null +++ b/src/Sidecar/Driver.php @@ -0,0 +1,86 @@ +|null + */ + public function entryClass(): ?string; + + /** + * Blueprint used when the collection has no blueprint files on disk. + */ + public function blueprint(): Blueprint; + + /** + * Customize the collection after it's been instantiated from config. + */ + public function configure(Collection $collection): Collection; + + /** + * Called after the collection has been registered during Sidecar boot. + */ + public function afterBoot(Collection $collection): void; + + /** + * Called after a sidecar-managed entry is saved. + */ + public function afterSave(Entry $entry): void; + + /** + * Called after a sidecar-managed entry is deleted. + */ + public function afterDelete(Entry $entry): void; + + /** + * Public URL for the entry (Visit URL + fallback Live Preview target). + */ + public function previewUrl(Entry $entry): ?string; + + /** + * Whether this driver stores nesting as real subfolders on disk + * (synced from the collection structure tree). + */ + public function usesNestedFolders(): bool; + + /** + * Filename (without extension) used for section/root index pages. + */ + public function indexFileName(): string; + + /** + * Persist a sibling position onto the entry (e.g. `order` front matter). + */ + public function syncOrder(Entry $entry, int $position): void; + + /** + * Called after a nested-folder tree sync relocates files / writes order. + */ + public function afterTreeSynced(\Statamic\Structures\CollectionTree $tree): void; +} diff --git a/src/Sidecar/Drivers/Driver.php b/src/Sidecar/Drivers/Driver.php new file mode 100644 index 00000000000..2f05a6fe595 --- /dev/null +++ b/src/Sidecar/Drivers/Driver.php @@ -0,0 +1,116 @@ +config['directory'] + ?? throw new \InvalidArgumentException("Sidecar collection [{$this->collectionHandle}] is missing a directory."); + } + + public function entryClass(): ?string + { + return $this->config['entry_class'] ?? null; + } + + public function blueprint(): BlueprintInstance + { + if ($handle = $this->config['blueprint'] ?? null) { + return Blueprint::find($handle) + ?? throw new \InvalidArgumentException("Sidecar blueprint [{$handle}] not found."); + } + + return $this->defaultBlueprint(); + } + + abstract protected function defaultBlueprint(): BlueprintInstance; + + public function configure(Collection $collection): Collection + { + return $collection + ->title($this->config['title'] ?? $this->title()) + ->directory($this->directory()) + ->entryClass($this->entryClass()) + // Store a Blueprint instance (not a Closure) so Stache can serialize + // the collection when SEO Pro / CP saves collection cascade data. + ->entryBlueprintFallback($this->blueprint()) + ->routes(null) + ->requiresSlugs(true); + } + + public function afterBoot(Collection $collection): void + { + // + } + + public function afterSave(Entry $entry): void + { + // + } + + public function afterDelete(Entry $entry): void + { + // + } + + public function previewUrl(Entry $entry): ?string + { + return null; + } + + public function usesNestedFolders(): bool + { + return false; + } + + public function indexFileName(): string + { + return '_index'; + } + + public function syncOrder(Entry $entry, int $position): void + { + if ((int) $entry->get('order') === $position) { + return; + } + + $entry->set('order', $position); + } + + public function afterTreeSynced(\Statamic\Structures\CollectionTree $tree): void + { + // + } + + public function collectionHandle(): string + { + return $this->collectionHandle; + } + + protected function makeBlueprint(array $contents): BlueprintInstance + { + return Blueprint::make(Str::singular($this->collectionHandle)) + ->setNamespace('collections.'.$this->collectionHandle) + ->setContents($contents); + } +} diff --git a/src/Sidecar/Entries/StoredInNestedFolders.php b/src/Sidecar/Entries/StoredInNestedFolders.php new file mode 100644 index 00000000000..33198769165 --- /dev/null +++ b/src/Sidecar/Entries/StoredInNestedFolders.php @@ -0,0 +1,240 @@ +resolveNestedFolderSlug(); + } + + if ($slug instanceof Closure) { + $this->slug = $slug; + + return $this; + } + + if (is_string($slug) && $slug === $this->nestedFolderIndexName() && $this->initialPath()) { + $slug = $this->slugDerivedFromIndexPath() ?? $slug; + } + + $this->slug = $slug; + + return $this; + } + + public function buildPath() + { + $directory = rtrim($this->collection()->resolvedDirectory(), '/'); + + return Path::tidy($directory.'/'.$this->nestedFolderRelativePath().'.'.$this->fileExtension()); + } + + /** + * URI path segments for public URLs (empty string for the structure root). + */ + public function nestedFolderUriPath(): string + { + if ($this->isStructureRoot()) { + return ''; + } + + $segments = $this->nestedFolderAncestrySegments(); + $segments[] = $this->slug() ?? $this->id(); + + return implode('/', array_filter($segments, fn ($s) => $s !== null && $s !== '')); + } + + /** + * Relative path key without extension (e.g. `guide/routing`, `guide/_index`). + */ + public function nestedFolderPathKey(): ?string + { + if ($relative = $this->relativePathFromInitialWithoutExtension()) { + return $relative; + } + + return $this->nestedFolderRelativePath(); + } + + protected function resolveNestedFolderSlug(): ?string + { + $slug = $this->slug; + + if ($slug instanceof Closure) { + $this->slug = null; + $slug = $slug($this); + $this->slug = $slug; + } + + if (! $slug) { + return null; + } + + $lang = method_exists($this, 'site') ? $this->site()->lang() : null; + + return Str::slug($slug, '-', $lang); + } + + protected function nestedFolderRelativePath(): string + { + $index = $this->nestedFolderIndexName(); + + if ($this->isStructureRoot()) { + return $index; + } + + $segments = $this->nestedFolderAncestrySegments(); + $slug = $this->slug() ?? $this->id(); + + if ($this->hasNestedFolderChildren()) { + $segments[] = $slug; + $segments[] = $index; + } else { + $segments[] = $slug; + } + + return implode('/', $segments); + } + + protected function nestedFolderAncestrySegments(): array + { + if ($page = $this->page()) { + $segments = []; + $parent = $page->parent(); + + while ($parent && ! $parent->isRoot()) { + array_unshift($segments, $parent->slug()); + $parent = $parent->parent(); + } + + return array_values(array_filter($segments, fn ($s) => filled($s))); + } + + return $this->ancestryFromInitialPath(); + } + + protected function ancestryFromInitialPath(): array + { + if (! $relative = $this->relativePathFromInitialWithoutExtension()) { + return []; + } + + $index = $this->nestedFolderIndexName(); + + if ($relative === $index) { + return []; + } + + if (Str::endsWith($relative, '/'.$index)) { + $parts = explode('/', Str::beforeLast($relative, '/'.$index)); + array_pop($parts); + + return array_values(array_filter($parts)); + } + + $parts = explode('/', $relative); + array_pop($parts); + + return array_values(array_filter($parts)); + } + + protected function hasNestedFolderChildren(): bool + { + if ($page = $this->page()) { + return $page->pages()->all()->isNotEmpty(); + } + + if (! $relative = $this->relativePathFromInitialWithoutExtension()) { + return false; + } + + $index = $this->nestedFolderIndexName(); + + return $relative !== $index && Str::endsWith($relative, '/'.$index); + } + + protected function isStructureRoot(): bool + { + // Tree::find() skips the expectsRoot page, so detect via the tree root branch. + if ($this->id() && ($structure = $this->structure()) && $structure->expectsRoot()) { + $root = $structure->in($this->locale())?->root(); + + if (($root['entry'] ?? null) === $this->id()) { + return true; + } + } + + $relative = $this->relativePathFromInitialWithoutExtension(); + + return $relative === $this->nestedFolderIndexName(); + } + + protected function slugDerivedFromIndexPath(): ?string + { + $relative = $this->relativePathFromInitialWithoutExtension(); + + if (! $relative) { + return null; + } + + $index = $this->nestedFolderIndexName(); + + if ($relative === $index) { + return 'index'; + } + + if (Str::endsWith($relative, '/'.$index)) { + return basename(Str::beforeLast($relative, '/'.$index)); + } + + return null; + } + + protected function relativePathFromInitialWithoutExtension(): ?string + { + if (! $this->initialPath() || ! $this->collection()) { + return null; + } + + $directory = Path::tidy(Str::finish($this->collection()->resolvedDirectory(), '/')); + $initial = Path::tidy($this->initialPath()); + + if (! Str::startsWith($initial, $directory)) { + return null; + } + + $relative = Str::after($initial, $directory); + + return Str::beforeLast($relative, '.'.$this->fileExtension()) ?: null; + } + + protected function nestedFolderIndexName(): string + { + $handle = $this->collectionHandle(); + + if ($handle && Sidecar::manages($handle)) { + return Sidecar::driver($handle)->indexFileName(); + } + + return '_index'; + } +} diff --git a/src/Sidecar/Manager.php b/src/Sidecar/Manager.php new file mode 100644 index 00000000000..5795229f51f --- /dev/null +++ b/src/Sidecar/Manager.php @@ -0,0 +1,198 @@ +customCreators[$driver] = $callback; + + return $this; + } + + /** + * Register a Sidecar driver package as compatible with an SSG/composer package. + * + * Used by `php please sidecar:install` to detect installed packages and + * offer the matching driver. Called from driver service providers. + */ + public function pair(string $compatiblePackage, string $driverPackage): self + { + $this->packages[$compatiblePackage] = $driverPackage; + + return $this; + } + + public function hasDriver(string $driver): bool + { + return isset($this->customCreators[$driver]); + } + + public function registeredDrivers(): array + { + return array_keys($this->customCreators); + } + + public function driver(string $collectionHandle): Driver + { + if (isset($this->resolved[$collectionHandle])) { + return $this->resolved[$collectionHandle]; + } + + $config = $this->getConfig($collectionHandle); + + if (is_null($config)) { + throw new InvalidArgumentException("Sidecar collection [{$collectionHandle}] is not defined."); + } + + $driver = $config['driver'] ?? null; + + if (! $driver) { + throw new InvalidArgumentException("Sidecar collection [{$collectionHandle}] is missing a driver."); + } + + if (! isset($this->customCreators[$driver])) { + throw new InvalidArgumentException("Sidecar driver [{$driver}] is not defined."); + } + + return $this->resolved[$collectionHandle] = $this->customCreators[$driver]( + app(), + $config, + $collectionHandle + ); + } + + public function collections(): IlluminateCollection + { + return collect(config('statamic.sidecar.collections', [])); + } + + public function handles(): IlluminateCollection + { + return $this->collections()->keys(); + } + + public function manages(string $collectionHandle): bool + { + return $this->collections()->has($collectionHandle); + } + + public function packages(): IlluminateCollection + { + return collect($this->packages); + } + + public function boot(): void + { + if ($this->booted) { + return; + } + + $this->booted = true; + + $this->collections()->each(function (array $config, string $handle) { + try { + $this->bootCollection($handle); + } catch (InvalidArgumentException $e) { + Log::warning('Sidecar: '.$e->getMessage()); + } + }); + + $this->registerEventListeners(); + } + + protected function bootCollection(string $handle): void + { + $driver = $this->driver($handle); + + // Start from an existing on-disk collection when present so persisted + // cascade data (e.g. SEO Pro section defaults) survives re-registration. + $collection = $driver->configure( + Collection::findByHandle($handle) ?? Collection::make($handle) + ); + + if ($previewUrl = $this->getConfig($handle)['preview_url'] ?? null) { + $collection->previewTargets([ + [ + 'label' => 'Site', + 'format' => $previewUrl, + 'refresh' => true, + ], + ]); + } + + Collection::register($collection); + + $driver->afterBoot($collection); + } + + protected function registerEventListeners(): void + { + if ($this->handles()->isEmpty()) { + return; + } + + Event::listen(EntrySaved::class, function (EntrySaved $event) { + $this->relayAfterSave($event->entry); + }); + + Event::listen(EntryDeleted::class, function (EntryDeleted $event) { + $this->relayAfterDelete($event->entry); + }); + + Event::listen(CollectionTreeSaved::class, [SyncTreeToFilesystem::class, 'handle']); + } + + protected function relayAfterSave(Entry $entry): void + { + $handle = $entry->collectionHandle(); + + if (! $this->manages($handle)) { + return; + } + + $this->driver($handle)->afterSave($entry); + } + + protected function relayAfterDelete(Entry $entry): void + { + $handle = $entry->collectionHandle(); + + if (! $this->manages($handle)) { + return; + } + + $this->driver($handle)->afterDelete($entry); + } + + protected function getConfig(string $name): ?array + { + $config = config("statamic.sidecar.collections.{$name}"); + + return is_array($config) ? $config : null; + } +} diff --git a/src/Sidecar/ServiceProvider.php b/src/Sidecar/ServiceProvider.php new file mode 100644 index 00000000000..886a24d9246 --- /dev/null +++ b/src/Sidecar/ServiceProvider.php @@ -0,0 +1,23 @@ +app->singleton(Manager::class, function () { + return new Manager; + }); + } + + public function boot() + { + Statamic::booted(function () { + $this->app->make(Manager::class)->boot(); + }); + } +} diff --git a/src/Sidecar/Structures/SyncTreeToFilesystem.php b/src/Sidecar/Structures/SyncTreeToFilesystem.php new file mode 100644 index 00000000000..d89d0e4177d --- /dev/null +++ b/src/Sidecar/Structures/SyncTreeToFilesystem.php @@ -0,0 +1,141 @@ +tree; + $handle = $tree->handle(); + + if (! Sidecar::manages($handle)) { + return; + } + + $driver = Sidecar::driver($handle); + + if (! $driver->usesNestedFolders()) { + return; + } + + $touchedDirectories = []; + $dirty = false; + + $this->syncBranches( + $tree->tree(), + $driver, + $touchedDirectories, + $dirty + ); + + $this->deleteEmptyDirectories( + $touchedDirectories, + Path::tidy($tree->collection()->resolvedDirectory()) + ); + + if ($dirty) { + $driver->afterTreeSynced($tree); + } + } + + protected function syncBranches(array $branches, Driver $driver, array &$touchedDirectories, bool &$dirty): void + { + foreach (array_values($branches) as $index => $branch) { + $id = $branch['entry'] ?? null; + + if (! $id || ! $entry = EntryFacade::find($id)) { + continue; + } + + if ($this->syncEntry($entry, $driver, $index + 1, $touchedDirectories)) { + $dirty = true; + } + + if (! empty($branch['children'])) { + $this->syncBranches($branch['children'], $driver, $touchedDirectories, $dirty); + } + } + } + + protected function syncEntry(Entry $entry, Driver $driver, int $position, array &$touchedDirectories): bool + { + $dirty = false; + $originalPath = $entry->path(); + + $driver->syncOrder($entry, $position); + + $expectedPath = Path::tidy($entry->buildPath()); + + if ($originalPath && Path::tidy($originalPath) !== $expectedPath) { + $touchedDirectories[] = Path::tidy(dirname($originalPath)); + $touchedDirectories[] = Path::tidy(dirname($expectedPath)); + $dirty = true; + } elseif ($entry->isDirty()) { + $dirty = true; + } + + if (! $dirty) { + return false; + } + + $entry->saveQuietly(); + + return true; + } + + protected function deleteEmptyDirectories(array $directories, string $collectionDirectory): void + { + $collectionDirectory = Path::tidy(rtrim($collectionDirectory, '/')); + + collect($directories) + ->filter() + ->flatMap(function ($directory) use ($collectionDirectory) { + $directory = Path::tidy(rtrim($directory, '/')); + $dirs = []; + + while ( + $directory + && $directory !== $collectionDirectory + && Str::startsWith($directory, $collectionDirectory.'/') + ) { + $dirs[] = $directory; + $directory = Path::tidy(dirname($directory)); + } + + return $dirs; + }) + ->unique() + ->sortByDesc(fn ($dir) => substr_count($dir, '/')) + ->each(function ($directory) { + if (! File::exists($directory) || ! File::isDirectory($directory)) { + return; + } + + if (! File::isEmpty($directory)) { + return; + } + + File::delete($directory); + }); + } +} diff --git a/src/Stache/Repositories/CollectionRepository.php b/src/Stache/Repositories/CollectionRepository.php index f5df6f3a88a..eacece469ab 100644 --- a/src/Stache/Repositories/CollectionRepository.php +++ b/src/Stache/Repositories/CollectionRepository.php @@ -17,6 +17,7 @@ class CollectionRepository implements RepositoryContract protected $stache; protected $store; protected $additionalPreviewTargets = []; + protected $registered = []; public function __construct(Stache $stache) { @@ -28,7 +29,11 @@ public function all(): IlluminateCollection { $keys = $this->store->paths()->keys(); - return $this->store->getItems($keys); + return $this->store + ->getItems($keys) + ->keyBy->handle() + ->merge($this->registered) + ->values(); } public function find($id): ?Collection @@ -38,7 +43,16 @@ public function find($id): ?Collection public function findByHandle($handle): ?Collection { - return $this->store->getItem($handle); + return $this->registered[$handle] ?? $this->store->getItem($handle); + } + + public function register(Collection $collection): void + { + $this->registered[$collection->handle()] = $collection; + + Blink::forget('collection-handles'); + Blink::forget('mounted-collections'); + Blink::forget("collection-{$collection->handle()}"); } public function findByMount($mount): ?Collection @@ -93,6 +107,12 @@ public function save(Collection $collection) public function delete(Collection $collection) { + unset($this->registered[$collection->handle()]); + + Blink::forget('collection-handles'); + Blink::forget('mounted-collections'); + Blink::forget("collection-{$collection->handle()}"); + $this->store->delete($collection); } diff --git a/src/Stache/Stores/CollectionEntriesStore.php b/src/Stache/Stores/CollectionEntriesStore.php index a56433f377f..ad3abd80617 100644 --- a/src/Stache/Stores/CollectionEntriesStore.php +++ b/src/Stache/Stores/CollectionEntriesStore.php @@ -41,16 +41,24 @@ public function getItemFilter(SplFileInfo $file) } if (Site::multiEnabled()) { - [$site, $relative] = explode('/', $relative, 2); - if (! $this->collection()->sites()->contains($site)) { + $site = Str::before($relative, '/'); + $hasSiteFolder = Str::contains($relative, '/') + && $this->collection()->sites()->contains($site); + + if ($hasSiteFolder) { + // Standard multi-site layout: {site}/entry.md + } elseif ($this->parent->customDirectory($this->childKey())) { + // Custom directories (Sidecar) may store entries flat — only when + // the collection includes the default site. + if (! $this->collection()->sites()->contains(Site::default()->handle())) { + return false; + } + } elseif (! $this->collection()->sites()->contains($site)) { + // Core multi-site: first path segment must be a site handle. return false; } } - // if (! Collection::findByHandle(explode('/', $relative)[0])) { - // return false; - // } - return $file->getExtension() !== 'yaml'; } @@ -114,16 +122,16 @@ public function makeItemFromFile($path, $contents) protected function extractAttributesFromPath($path) { $site = Site::default()->handle(); - $collection = pathinfo($path, PATHINFO_DIRNAME); - $collection = Str::after($collection, $this->parent->directory()); + $collection = $this->childKey(); if (Site::multiEnabled()) { - [$collection, $site] = explode('/', $collection); - } + $dir = Str::finish($this->directory(), '/'); + $relative = Str::after(Path::tidy($path), $dir); + $maybeSite = Str::before($relative, '/'); - // Support entries within subdirectories at any level. - if (Str::contains($collection, '/')) { - $collection = Str::before($collection, '/'); + if ($maybeSite && Str::contains($relative, '/') && $this->collection()->sites()->contains($maybeSite)) { + $site = $maybeSite; + } } return [$collection, $site]; diff --git a/src/Stache/Stores/CollectionsStore.php b/src/Stache/Stores/CollectionsStore.php index d15399a80ae..c5db741b6e9 100644 --- a/src/Stache/Stores/CollectionsStore.php +++ b/src/Stache/Stores/CollectionsStore.php @@ -61,7 +61,8 @@ public function makeItemFromFile($path, $contents) ->propagate(Arr::get($data, 'propagate')) ->previewTargets($this->normalizePreviewTargets(Arr::get($data, 'preview_targets', []))) ->autosaveInterval(Arr::get($data, 'autosave')) - ->entryClass(Arr::get($data, 'entry_class')); + ->entryClass(Arr::get($data, 'entry_class')) + ->directory(Arr::get($data, 'directory')); if ($dateBehavior = Arr::get($data, 'date_behavior')) { $collection diff --git a/src/Stache/Stores/EntriesStore.php b/src/Stache/Stores/EntriesStore.php index 3e5209b72f2..70bb02da3cc 100644 --- a/src/Stache/Stores/EntriesStore.php +++ b/src/Stache/Stores/EntriesStore.php @@ -2,12 +2,14 @@ namespace Statamic\Stache\Stores; -use Statamic\Facades\Collection; +use Statamic\Facades\Path; class EntriesStore extends AggregateStore { protected $childStore = CollectionEntriesStore::class; + protected $customDirectories = []; + public function key() { return 'entries'; @@ -15,8 +17,37 @@ public function key() public function discoverStores() { - return Collection::handles()->map(function ($handle) { + return \Statamic\Facades\Collection::handles()->map(function ($handle) { return $this->store($handle); }); } + + public function setCustomDirectory(string $handle, ?string $directory): self + { + if ($directory) { + $this->customDirectories[$handle] = $directory; + } else { + unset($this->customDirectories[$handle]); + } + + return $this; + } + + public function customDirectory(string $handle): ?string + { + return $this->customDirectories[$handle] ?? null; + } + + public function childDirectory($child) + { + $handle = $child->childKey(); + + if ($directory = $this->customDirectory($handle)) { + return Path::tidy( + Path::isAbsolute($directory) ? $directory : base_path($directory) + ); + } + + return parent::childDirectory($child); + } } diff --git a/tests/Data/Entries/CollectionTest.php b/tests/Data/Entries/CollectionTest.php index a62e7f0d1a4..75336184230 100644 --- a/tests/Data/Entries/CollectionTest.php +++ b/tests/Data/Entries/CollectionTest.php @@ -224,6 +224,43 @@ public function it_gets_and_sets_the_title() $this->assertEquals('The Blog', $collection->title()); } + #[Test] + public function it_gets_and_sets_the_directory() + { + $collection = (new Collection)->handle('docs'); + $this->assertNull($collection->directory()); + + $return = $collection->directory('docs'); + + $this->assertEquals($collection, $return); + $this->assertEquals('docs', $collection->directory()); + } + + #[Test] + public function it_resolves_relative_and_absolute_directories() + { + $collection = (new Collection)->handle('docs'); + + $this->assertEquals( + Facades\Path::tidy($this->fakeStacheDirectory.'/content/collections/docs'), + $collection->resolvedDirectory() + ); + + $collection->directory('docs'); + $this->assertEquals(Facades\Path::tidy(base_path('docs')), $collection->resolvedDirectory()); + + $collection->directory('/absolute/path/to/docs'); + $this->assertEquals(Facades\Path::tidy('/absolute/path/to/docs'), $collection->resolvedDirectory()); + } + + #[Test] + public function directory_is_included_in_file_data() + { + $collection = (new Collection)->handle('docs')->directory('docs'); + + $this->assertEquals('docs', $collection->fileData()['directory']); + } + #[Test] public function it_gets_and_sets_the_sites_it_can_be_used_in_when_using_multiple_sites() { @@ -408,6 +445,39 @@ public function no_existing_blueprints_will_fall_back_to_a_default_named_after_t $this->assertNull($collection->entryBlueprint('two')); } + #[Test] + public function custom_entry_blueprint_fallback_sets_title_from_collection() + { + BlueprintRepository::shouldReceive('in')->with('collections/articles')->andReturn(collect()); + BlueprintRepository::shouldReceive('getAdditionalNamespaces')->andReturn(collect()); + + $fallback = (new Blueprint) + ->setHandle('doc') + ->setContents(['title' => 'Stale Title', 'fields' => [ + ['handle' => 'content', 'field' => ['type' => 'markdown']], + ]]); + + $fromInstance = (new Collection) + ->handle('articles') + ->title('Articles') + ->entryBlueprintFallback($fallback) + ->entryBlueprint(); + + $this->assertEquals('Article', $fromInstance->title()); + $this->assertEquals('doc', $fromInstance->handle()); + $this->assertEquals('collections.articles', $fromInstance->namespace()); + + $fromClosure = (new Collection) + ->handle('articles') + ->title('Articles') + ->entryBlueprintFallback(fn () => clone $fallback) + ->entryBlueprint(); + + $this->assertEquals('Article', $fromClosure->title()); + $this->assertEquals('doc', $fromClosure->handle()); + $this->assertEquals('collections.articles', $fromClosure->namespace()); + } + #[Test] public function it_dispatches_an_event_when_getting_entry_blueprint() { @@ -901,6 +971,29 @@ public function it_updates_entry_parents_through_the_entry_repository() $collection->updateEntryParent(['one', 'two']); } + #[Test] + public function it_enables_live_preview_with_routes_or_custom_preview_targets() + { + $this->setSites([ + 'en' => ['url' => 'http://domain.com/'], + ]); + + $collection = (new Collection)->handle('test'); + + $this->assertFalse($collection->hasLivePreview()); + $this->assertFalse($collection->hasLivePreview('en')); + + $collection->routes('{slug}'); + $this->assertTrue($collection->hasLivePreview()); + $this->assertTrue($collection->hasLivePreview('en')); + + $collection->routes(null)->previewTargets([ + ['label' => 'Docs', 'format' => '/!/sidecar/preview'], + ]); + $this->assertTrue($collection->hasLivePreview()); + $this->assertTrue($collection->hasLivePreview('en')); + } + #[Test] #[DataProvider('additionalPreviewTargetProvider')] public function it_gets_and_sets_preview_targets($throughFacade) diff --git a/tests/Data/Entries/EntryTest.php b/tests/Data/Entries/EntryTest.php index 2f800df2f60..012ee8a2b37 100644 --- a/tests/Data/Entries/EntryTest.php +++ b/tests/Data/Entries/EntryTest.php @@ -922,6 +922,61 @@ public function it_gets_the_path_and_excludes_locale_when_theres_a_single_site() $this->assertEquals($this->fakeStacheDirectory.'/content/collections/blog/2018-01-02.post.md', $entry->date('2018-01-02')->path()); } + #[Test] + public function it_gets_the_path_from_a_custom_collection_directory() + { + $this->setSites([ + 'en' => ['url' => '/', 'locale' => 'en_US'], + ]); + + $directory = $this->fakeStacheDirectory.'/custom-docs'; + $collection = tap(Facades\Collection::make('docs')->directory($directory))->save(); + $entry = (new Entry)->collection($collection)->locale('en')->slug('getting-started'); + + $this->assertEquals($directory.'/getting-started.md', $entry->path()); + } + + #[Test] + public function it_preserves_unknown_front_matter_keys_on_save() + { + $collection = tap(Facades\Collection::make('docs'))->save(); + + $path = $this->fakeStacheDirectory.'/content/collections/docs/getting-started.md'; + app('files')->makeDirectory(dirname($path), 0755, true); + app('files')->put($path, <<<'MD' +--- +id: abc-123 +title: Getting Started +order: 1 +group: Basics +badge: New +custom_ssg_key: keep-me +--- +Hello world. +MD); + + Facades\Stache::store('entries')->store('docs')->clearCachedPaths(); + + $entry = Facades\Entry::query()->where('collection', 'docs')->where('slug', 'getting-started')->first(); + + $this->assertNotNull($entry); + $this->assertEquals('keep-me', $entry->get('custom_ssg_key')); + $this->assertEquals('Basics', $entry->get('group')); + $this->assertEquals(1, $entry->get('order')); + + // Simulate a CP save that only updates blueprint-known fields. + $entry->merge(['title' => 'Getting Started Updated', 'order' => 2])->save(); + + $contents = app('files')->get($path); + + $this->assertStringContainsString('custom_ssg_key: keep-me', $contents); + $this->assertStringContainsString('group: Basics', $contents); + $this->assertStringContainsString('badge: New', $contents); + $this->assertStringContainsString("title: 'Getting Started Updated'", $contents); + $this->assertStringContainsString('order: 2', $contents); + $this->assertStringContainsString('Hello world.', $contents); + } + #[Test] public function it_gets_the_path_and_includes_locale_when_theres_multiple_sites() { diff --git a/tests/Fields/BlueprintTest.php b/tests/Fields/BlueprintTest.php index e9e4232f41c..801603f2fa7 100644 --- a/tests/Fields/BlueprintTest.php +++ b/tests/Fields/BlueprintTest.php @@ -492,6 +492,7 @@ public function converts_to_array_suitable_for_rendering_fields_in_publish_compo 'type' => 'textarea', 'placeholder' => null, 'character_limit' => null, + 'rows' => 3, 'default' => null, 'antlers' => false, 'component' => 'textarea', diff --git a/tests/Fields/FieldsTest.php b/tests/Fields/FieldsTest.php index 5375cd34dd7..82e6efedc3d 100644 --- a/tests/Fields/FieldsTest.php +++ b/tests/Fields/FieldsTest.php @@ -457,6 +457,7 @@ public function converts_to_array_suitable_for_rendering_fields_in_publish_compo 'replicator_preview' => true, 'duplicate' => true, 'actions' => true, + 'rows' => 3, ], ], $fields->toPublishArray()); } diff --git a/tests/Fields/SectionTest.php b/tests/Fields/SectionTest.php index cc9437a7bc6..06d06f14545 100644 --- a/tests/Fields/SectionTest.php +++ b/tests/Fields/SectionTest.php @@ -156,6 +156,7 @@ public function converts_to_array_suitable_for_rendering_fields_in_publish_compo 'type' => 'textarea', 'placeholder' => null, 'character_limit' => null, + 'rows' => 3, 'default' => null, 'antlers' => false, 'component' => 'textarea', diff --git a/tests/Fields/TabTest.php b/tests/Fields/TabTest.php index 11b4d4e0d63..5be98d5e6cc 100644 --- a/tests/Fields/TabTest.php +++ b/tests/Fields/TabTest.php @@ -183,6 +183,7 @@ public function converts_to_array_suitable_for_rendering_fields_in_publish_compo 'type' => 'textarea', 'placeholder' => null, 'character_limit' => null, + 'rows' => 3, 'default' => null, 'antlers' => false, 'component' => 'textarea', diff --git a/tests/Rules/SlugTest.php b/tests/Rules/SlugTest.php index e31bfb1d826..dfe5b131e14 100644 --- a/tests/Rules/SlugTest.php +++ b/tests/Rules/SlugTest.php @@ -39,6 +39,8 @@ public function it_validates_slugs() $this->assertFails('foo-!bar'); $this->assertFails('foo_!bar'); $this->assertFails('foo-_-bar'); + $this->assertFails('guide/routing'); + $this->assertFails('_index'); } #[Test] diff --git a/tests/Sidecar/ManagerTest.php b/tests/Sidecar/ManagerTest.php new file mode 100644 index 00000000000..2598881f0e5 --- /dev/null +++ b/tests/Sidecar/ManagerTest.php @@ -0,0 +1,158 @@ +manager = new Manager; + + $this->manager->extend('fake', function ($app, $config, $handle) { + return new class($config, $handle) extends Driver + { + public function title(): string + { + return 'Fake Docs'; + } + + protected function defaultBlueprint(): BlueprintInstance + { + return $this->makeBlueprint([ + 'title' => 'Fake Doc', + 'fields' => [ + ['handle' => 'content', 'field' => ['type' => 'markdown']], + ['handle' => 'order', 'field' => ['type' => 'integer']], + ], + ]); + } + + public function afterSave(Entry $entry): void + { + $entry->set('saved_hook', true); + } + + public function previewUrl(Entry $entry): ?string + { + return url('fake-docs/'.$entry->slug()); + } + }; + }); + + $this->app->instance(Manager::class, $this->manager); + } + + #[Test] + public function it_registers_collections_from_config() + { + config(['statamic.sidecar.collections' => [ + 'docs' => [ + 'driver' => 'fake', + 'directory' => $this->fakeStacheDirectory.'/docs', + ], + ]]); + + $this->manager->boot(); + + $collection = CollectionAPI::findByHandle('docs'); + + $this->assertInstanceOf(Collection::class, $collection); + $this->assertEquals('Fake Docs', $collection->title()); + $this->assertEquals($this->fakeStacheDirectory.'/docs', $collection->directory()); + $this->assertTrue($this->manager->manages('docs')); + $this->assertFalse(file_exists($this->fakeStacheDirectory.'/content/collections/docs.yaml')); + } + + #[Test] + public function it_uses_config_title_override() + { + config(['statamic.sidecar.collections' => [ + 'docs' => [ + 'driver' => 'fake', + 'directory' => $this->fakeStacheDirectory.'/docs', + 'title' => 'Documentation', + ], + ]]); + + $this->manager->boot(); + + $this->assertEquals('Documentation', CollectionAPI::findByHandle('docs')->title()); + } + + #[Test] + public function it_provides_a_fallback_blueprint() + { + config(['statamic.sidecar.collections' => [ + 'docs' => [ + 'driver' => 'fake', + 'directory' => $this->fakeStacheDirectory.'/docs', + ], + ]]); + + $this->manager->boot(); + + $blueprint = CollectionAPI::findByHandle('docs')->entryBlueprint(); + + $this->assertInstanceOf(BlueprintInstance::class, $blueprint); + $this->assertTrue($blueprint->fields()->all()->has('content')); + $this->assertTrue($blueprint->fields()->all()->has('order')); + } + + #[Test] + public function it_can_extend_custom_drivers() + { + $this->assertTrue(Sidecar::hasDriver('fake')); + $this->assertContains('fake', Sidecar::registeredDrivers()); + } + + #[Test] + public function it_registers_compatible_packages_from_drivers() + { + $this->manager->pair('acme/docs', 'statamic/sidecar-acme'); + + $this->assertEquals([ + 'acme/docs' => 'statamic/sidecar-acme', + ], $this->manager->packages()->all()); + } + + #[Test] + public function it_resolves_entry_urls_from_driver_preview_url() + { + config(['statamic.sidecar.collections' => [ + 'docs' => [ + 'driver' => 'fake', + 'directory' => $this->fakeStacheDirectory.'/docs', + ], + ]]); + + $this->manager->boot(); + + $entry = \Statamic\Facades\Entry::make() + ->collection('docs') + ->id('doc-1') + ->slug('getting-started') + ->data(['title' => 'Getting Started']); + + $this->assertEquals('/fake-docs/getting-started', $entry->uri()); + $this->assertEquals('/fake-docs/getting-started', $entry->url()); + $this->assertNotNull($entry->absoluteUrl()); + $this->assertStringEndsWith('/fake-docs/getting-started', $entry->absoluteUrl()); + } +} diff --git a/tests/Sidecar/NestedFoldersTest.php b/tests/Sidecar/NestedFoldersTest.php new file mode 100644 index 00000000000..3517099d40a --- /dev/null +++ b/tests/Sidecar/NestedFoldersTest.php @@ -0,0 +1,250 @@ +docsDir = $this->fakeStacheDirectory.'/nested-docs'; + File::ensureDirectoryExists($this->docsDir); + + $this->manager = new Manager; + + $this->manager->extend('nested', function ($app, $config, $handle) { + return new class($config, $handle) extends Driver + { + public function title(): string + { + return 'Nested Docs'; + } + + public function entryClass(): ?string + { + return NestedFolderEntry::class; + } + + public function usesNestedFolders(): bool + { + return true; + } + + public function configure(\Statamic\Entries\Collection $collection): \Statamic\Entries\Collection + { + return parent::configure($collection) + ->structureContents(['root' => true]) + ->sites(['en']); + } + + protected function defaultBlueprint(): BlueprintInstance + { + return $this->makeBlueprint([ + 'title' => 'Doc', + 'fields' => [ + ['handle' => 'content', 'field' => ['type' => 'markdown']], + ], + ]); + } + + public function previewUrl(EntryContract $entry): ?string + { + $path = $entry->nestedFolderUriPath(); + + return $path === '' ? url('docs') : url('docs/'.$path); + } + }; + }); + + $this->app->instance(Manager::class, $this->manager); + + config(['statamic.sidecar.collections' => [ + 'docs' => [ + 'driver' => 'nested', + 'directory' => $this->docsDir, + ], + ]]); + + $this->manager->boot(); + } + + #[Test] + public function it_builds_paths_from_tree_ancestry() + { + $root = $this->makeDoc('index', 'Home', '_index.md'); + $guide = $this->makeDoc('guide', 'Guide', 'guide.md'); + $routing = $this->makeDoc('routing', 'Routing', 'routing.md'); + + // expectsRoot: root is tree[0] with no children key; top-level pages follow. + $this->saveTree([ + ['entry' => $root->id()], + ['entry' => $guide->id(), 'children' => [ + ['entry' => $routing->id()], + ]], + ]); + + $this->assertEquals($this->docsDir.'/_index.md', Path::tidy($root->fresh()->path())); + $this->assertEquals($this->docsDir.'/guide/_index.md', Path::tidy($guide->fresh()->path())); + $this->assertEquals($this->docsDir.'/guide/routing.md', Path::tidy($routing->fresh()->path())); + + $this->assertFileExists($this->docsDir.'/_index.md'); + $this->assertFileExists($this->docsDir.'/guide/_index.md'); + $this->assertFileExists($this->docsDir.'/guide/routing.md'); + $this->assertFileDoesNotExist($this->docsDir.'/guide.md'); + $this->assertFileDoesNotExist($this->docsDir.'/routing.md'); + } + + #[Test] + public function it_converts_section_back_to_leaf_when_last_child_is_removed() + { + $root = $this->makeDoc('index', 'Home', '_index.md'); + $guide = $this->makeDoc('guide', 'Guide', 'guide.md'); + $routing = $this->makeDoc('routing', 'Routing', 'routing.md'); + + $this->saveTree([ + ['entry' => $root->id()], + ['entry' => $guide->id(), 'children' => [ + ['entry' => $routing->id()], + ]], + ]); + + $this->saveTree([ + ['entry' => $root->id()], + ['entry' => $guide->id()], + ['entry' => $routing->id()], + ]); + + $this->assertEquals($this->docsDir.'/guide.md', Path::tidy($guide->fresh()->path())); + $this->assertEquals($this->docsDir.'/routing.md', Path::tidy($routing->fresh()->path())); + $this->assertFileExists($this->docsDir.'/guide.md'); + $this->assertFileDoesNotExist($this->docsDir.'/guide/_index.md'); + $this->assertDirectoryDoesNotExist($this->docsDir.'/guide'); + } + + #[Test] + public function it_syncs_per_level_order_front_matter() + { + $root = $this->makeDoc('index', 'Home', '_index.md'); + $a = $this->makeDoc('alpha', 'Alpha', 'alpha.md'); + $b = $this->makeDoc('bravo', 'Bravo', 'bravo.md'); + + $this->saveTree([ + ['entry' => $root->id()], + ['entry' => $b->id()], + ['entry' => $a->id()], + ]); + + // Root is position 1; top-level siblings follow. + $this->assertEquals(1, $root->fresh()->get('order')); + $this->assertEquals(2, $b->fresh()->get('order')); + $this->assertEquals(3, $a->fresh()->get('order')); + } + + #[Test] + public function it_hydrates_index_file_slugs_from_folder_names() + { + $section = EntryAPI::make() + ->id('guide-1') + ->collection('docs') + ->locale('en') + ->initialPath($this->docsDir.'/guide/_index.md') + ->slug('_index') + ->data(['title' => 'Guide']); + + $root = EntryAPI::make() + ->id('root-1') + ->collection('docs') + ->locale('en') + ->initialPath($this->docsDir.'/_index.md') + ->slug('_index') + ->data(['title' => 'Home']); + + $this->assertInstanceOf(NestedFolderEntry::class, $section); + $this->assertEquals('guide', $section->slug()); + $this->assertEquals('index', $root->slug()); + } + + #[Test] + public function new_entries_build_paths_at_the_collection_root() + { + $entry = EntryAPI::make() + ->id('new-1') + ->collection('docs') + ->locale('en') + ->slug('fresh-page') + ->data(['title' => 'Fresh']); + + $this->assertEquals($this->docsDir.'/fresh-page.md', Path::tidy($entry->buildPath())); + } + + #[Test] + public function it_resolves_preview_urls_from_tree_ancestry() + { + $root = $this->makeDoc('index', 'Home', '_index.md'); + $guide = $this->makeDoc('guide', 'Guide', 'guide.md'); + $routing = $this->makeDoc('routing', 'Routing', 'routing.md'); + + $this->saveTree([ + ['entry' => $root->id()], + ['entry' => $guide->id(), 'children' => [ + ['entry' => $routing->id()], + ]], + ]); + + $this->assertEquals('/docs', $root->fresh()->uri()); + $this->assertEquals('/docs/guide', $guide->fresh()->uri()); + $this->assertEquals('/docs/guide/routing', $routing->fresh()->uri()); + } + + private function makeDoc(string $slug, string $title, string $relativePath): Entry + { + $path = $this->docsDir.'/'.$relativePath; + File::ensureDirectoryExists(dirname($path)); + + $entry = EntryAPI::make() + ->id(Str::uuid()->toString()) + ->collection('docs') + ->locale('en') + ->slug($slug) + ->data(['title' => $title, 'content' => '# '.$title]) + ->published(true); + + // Write via buildPath once so initialPath is set; tree sync relocates later. + File::put($path, $entry->fileContents()); + $entry->initialPath($path)->saveQuietly(); + + return $entry; + } + + private function saveTree(array $branches): void + { + CollectionAPI::find('docs')->structure()->in('en')->tree($branches)->save(); + } +} + +class NestedFolderEntry extends Entry +{ + use StoredInNestedFolders; +} diff --git a/tests/Stache/Repositories/CollectionRepositoryTest.php b/tests/Stache/Repositories/CollectionRepositoryTest.php index 46ba14580a2..ec0fe6ea123 100644 --- a/tests/Stache/Repositories/CollectionRepositoryTest.php +++ b/tests/Stache/Repositories/CollectionRepositoryTest.php @@ -119,6 +119,33 @@ public function it_gets_additional_preview_targets() $this->assertNotEquals($previewTargetsTest->all(), $previewTargetsTest2->all()); } + #[Test] + public function it_registers_a_collection_without_a_file() + { + $collection = CollectionAPI::make('docs')->title('Docs')->directory('/tmp/docs'); + + $this->assertNull($this->repo->findByHandle('docs')); + $this->assertFalse($this->repo->handleExists('docs')); + + $this->repo->register($collection); + + $this->assertSame($collection, $this->repo->findByHandle('docs')); + $this->assertTrue($this->repo->handleExists('docs')); + $this->assertTrue($this->repo->all()->map->handle()->contains('docs')); + $this->assertFalse(file_exists($this->directory.'/docs.yaml')); + } + + #[Test] + public function a_registered_collection_takes_precedence_over_a_file_based_one() + { + $registered = CollectionAPI::make('blog')->title('Registered Blog'); + + $this->repo->register($registered); + + $this->assertSame($registered, $this->repo->findByHandle('blog')); + $this->assertEquals('Registered Blog', $this->repo->findByHandle('blog')->title()); + } + #[Test] public function test_find_or_fail_gets_collection() { diff --git a/tests/Stache/Stores/CollectionsStoreTest.php b/tests/Stache/Stores/CollectionsStoreTest.php index 3b19365ac30..d86da99124b 100644 --- a/tests/Stache/Stores/CollectionsStoreTest.php +++ b/tests/Stache/Stores/CollectionsStoreTest.php @@ -69,6 +69,14 @@ public function it_makes_collection_instances_from_files() $this->assertEquals('Example', $item->title()); } + #[Test] + public function it_makes_collection_instances_with_a_custom_directory() + { + $item = $this->store->makeItemFromFile($this->tempDir.'/example.yaml', "title: Example\ndirectory: docs"); + + $this->assertEquals('docs', $item->directory()); + } + #[Test] public function it_normalizes_preview_target_url_into_format() { diff --git a/tests/Stache/Stores/EntriesStoreTest.php b/tests/Stache/Stores/EntriesStoreTest.php index 705a70f3c75..444ea5de630 100644 --- a/tests/Stache/Stores/EntriesStoreTest.php +++ b/tests/Stache/Stores/EntriesStoreTest.php @@ -83,6 +83,89 @@ public function it_gets_nested_files() }); } + #[Test] + public function it_uses_a_custom_collection_directory() + { + $customDir = Path::tidy(sys_get_temp_dir().'/statamic-sidecar-entries-'.uniqid()); + mkdir($customDir); + touch($customDir.'/hello.md', 1234567890); + + $this->parent->setCustomDirectory('docs', $customDir); + + $store = $this->parent->store('docs'); + + $this->assertEquals($customDir, Path::tidy($store->directory())); + + $files = Traverser::filter([$store, 'getItemFilter'])->traverse($store); + + $this->assertEquals([$customDir.'/hello.md'], $files->keys()->all()); + + (new \Illuminate\Filesystem\Filesystem)->deleteDirectory($customDir); + } + + #[Test] + public function multisite_collections_still_require_site_folders_without_a_custom_directory() + { + $this->setSites([ + 'en' => ['url' => 'http://localhost/', 'locale' => 'en'], + 'fr' => ['url' => 'http://localhost/fr/', 'locale' => 'fr'], + ]); + + $dir = Path::tidy(sys_get_temp_dir().'/statamic-sidecar-multisite-'.uniqid()); + mkdir($dir.'/pages/en', 0777, true); + mkdir($dir.'/pages/fr', 0777, true); + mkdir($dir.'/pages/notasite', 0777, true); + touch($dir.'/pages/en/about.md'); + touch($dir.'/pages/loose.md'); // flat file — must be ignored in core multi-site + touch($dir.'/pages/notasite/nope.md'); + + $this->parent->directory($dir); + Facades\Collection::shouldReceive('findByHandle')->with('pages')->andReturn( + (new \Statamic\Entries\Collection)->handle('pages')->sites(['en', 'fr']) + ); + + $files = Traverser::filter([$this->parent->store('pages'), 'getItemFilter']) + ->traverse($this->parent->store('pages')); + + $this->assertEquals([$dir.'/pages/en/about.md'], $files->keys()->all()); + + (new \Illuminate\Filesystem\Filesystem)->deleteDirectory($dir); + } + + #[Test] + public function custom_directories_may_store_flat_entries_under_multisite() + { + $this->setSites([ + 'en' => ['url' => 'http://localhost/', 'locale' => 'en'], + 'fr' => ['url' => 'http://localhost/fr/', 'locale' => 'fr'], + ]); + + $customDir = Path::tidy(sys_get_temp_dir().'/statamic-sidecar-flat-'.uniqid()); + mkdir($customDir.'/guide', 0777, true); + touch($customDir.'/hello.md'); + touch($customDir.'/guide/routing.md'); + + // Collection::handle() re-registers a null directory onto the Stache store, + // so set the custom directory after constructing the collection mock. + Facades\Collection::shouldReceive('findByHandle')->with('docs')->andReturn( + (new \Statamic\Entries\Collection)->handle('docs')->sites(['en', 'fr']) + ); + $this->parent->setCustomDirectory('docs', $customDir); + + $store = $this->parent->store('docs'); + + $this->assertEquals($customDir, Path::tidy($store->directory())); + + $files = Traverser::filter([$store, 'getItemFilter'])->traverse($store); + + $this->assertEqualsCanonicalizing([ + $customDir.'/hello.md', + $customDir.'/guide/routing.md', + ], $files->keys()->all()); + + (new \Illuminate\Filesystem\Filesystem)->deleteDirectory($customDir); + } + #[Test] public function it_makes_entry_instances_from_files() {