Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions config/sidecar.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

return [

/*
|--------------------------------------------------------------------------
| Sidecar Collections
|--------------------------------------------------------------------------
|
| Map collection handles to Sidecar drivers. Each driver adapts an external
| content directory (e.g. a LaraDocs `docs/` folder or Jigsaw
| `source/docs/`) into a Statamic collection editable in the Control Panel.
|
| @experimental
|
*/

'collections' => [

// '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',
// ],

],

];
1 change: 1 addition & 0 deletions resources/js/components/fieldtypes/TextareaFieldtype.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
184 changes: 184 additions & 0 deletions src/Console/Commands/SidecarInstall.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
<?php

namespace Statamic\Console\Commands;

use Facades\Statamic\Console\Processes\Composer;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Statamic\Console\EnhancesCommands;
use Statamic\Console\RunsInPlease;
use Statamic\Facades\Sidecar;
use Statamic\Support\Str;

use function Laravel\Prompts\confirm;
use function Laravel\Prompts\error;
use function Laravel\Prompts\info;
use function Laravel\Prompts\select;
use function Laravel\Prompts\spin;

class SidecarInstall extends Command
{
use EnhancesCommands, RunsInPlease;

protected $signature = 'statamic:sidecar:install {driver? : The Sidecar driver to install}';

protected $description = 'Install & configure a Sidecar collection adapter driver';

public function handle()
{
$driver = $this->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 = <<<PHP

'{$handle}' => [
'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}')",
};
}
}
2 changes: 2 additions & 0 deletions src/Contracts/Entries/CollectionRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,6 @@ public function handles(): IlluminateCollection;
public function handleExists(string $handle): bool;

public function whereStructured(): IlluminateCollection;

public function register(Collection $collection): void;
}
90 changes: 87 additions & 3 deletions src/Entries/Collection.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -69,6 +70,8 @@ class Collection implements Arrayable, ArrayAccess, AugmentableContract, Contain
protected $previewTargets = [];
protected $autosave;
protected $withEvents = true;
protected $directory;
protected $entryBlueprintFallback;

protected $entryClass;

Expand All @@ -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;
}

Expand Down Expand Up @@ -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());
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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, [
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading