From 0f2fa01388dc7c71d969794c508e083fb9934bfb Mon Sep 17 00:00:00 2001 From: Rias Van der Veken Date: Wed, 4 Nov 2026 06:58:36 +0000 Subject: [PATCH] Add a persistent Craft 6 development workbench Seed a local SQLite app for rich text, nested entries, and images. Keep its data separate from tests and document setup and asset publishing. Amp-Thread-ID: https://ampcode.com/threads/T-01a1a339-6451-75b0-b9a6-5797417cf757 Co-authored-by: Amp --- .gitattributes | 2 + .github/workflows/ci.yml | 1 + .prettierignore | 8 ++ README.md | 19 ++++ composer.json | 14 ++- testbench.yaml | 22 +++++ workbench/.env.example | 17 ++++ workbench/.gitignore | 13 +++ .../Providers/WorkbenchServiceProvider.php | 30 ++++++ workbench/bootstrap/app.php | 31 ++++++ workbench/composer.json | 9 ++ workbench/config/filesystems.php | 14 +++ workbench/config/workbench.php | 9 ++ workbench/database/seeders/DatabaseSeeder.php | 59 +++++++++++ workbench/database/seeders/EditorSeeder.php | 97 +++++++++++++++++++ workbench/prepare.php | 31 ++++++ workbench/public/index.php | 16 +++ workbench/routes/web.php | 7 ++ workbench/server.php | 12 +++ 19 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 testbench.yaml create mode 100644 workbench/.env.example create mode 100644 workbench/.gitignore create mode 100644 workbench/app/Providers/WorkbenchServiceProvider.php create mode 100644 workbench/bootstrap/app.php create mode 100644 workbench/composer.json create mode 100644 workbench/config/filesystems.php create mode 100644 workbench/config/workbench.php create mode 100644 workbench/database/seeders/DatabaseSeeder.php create mode 100644 workbench/database/seeders/EditorSeeder.php create mode 100644 workbench/prepare.php create mode 100644 workbench/public/index.php create mode 100644 workbench/routes/web.php create mode 100644 workbench/server.php diff --git a/.gitattributes b/.gitattributes index 45875229..14567f09 100644 --- a/.gitattributes +++ b/.gitattributes @@ -23,6 +23,8 @@ /tests/ export-ignore /.github/ export-ignore /.husky/ export-ignore +/workbench/ export-ignore +/testbench.yaml export-ignore # Identify generated files /resources/assets/*/dist/** linguist-generated=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d305268..a941851c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,7 @@ jobs: extensions: gd, pdo_sqlite tools: composer:v2 - run: composer install --no-interaction --no-ansi --no-progress + - run: php workbench/prepare.php - run: composer phpstan notify-slack: name: Notify Slack diff --git a/.prettierignore b/.prettierignore index cf0293d4..ae801d9d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -8,3 +8,11 @@ tests/_craft/* vendor/* resources/assets/**/dist public/build +workbench/bootstrap/cache +workbench/config/craft +workbench/public/vendor +workbench/public/uploads +workbench/storage +workbench/vendor +.amp/in +.amp/portals diff --git a/README.md b/README.md index ab8c1f24..b78d4265 100644 --- a/README.md +++ b/README.md @@ -468,3 +468,22 @@ If you wish to use CKEditor on the front end, the safest option is to [bring you ``` You may need to build up the top-level config object from `field.options`, `field.headingLevels`, and so on to get a complete, functional editor—be mindful that configurations may rely on plugins that are only available in the control panel! Refer to [`CraftCms\Ckeditor\Field::uiControl()`](src/Field.php) for additional steps the CKEditor plugin when normalizing configuration, for control panel editor instances. + +## Development workbench + +The workbench runs a persistent Craft 6 application with SQLite. It seeds an Articles section with plain text, CKEditor, nested Quote entries, and Photo entries backed by a local image volume. + +With PHP 8.5, Composer, and Node installed, run: + +```sh +composer install +npm ci +composer workbench:setup +composer serve +``` + +Open `http://localhost:8125/admin` on the machine running the server. Sign in as `admin` with the generated `WORKBENCH_ADMIN_PASSWORD` in `workbench/.env`. The workbench does not bypass authentication. Do not use it for production data. + +`composer workbench:setup` publishes the installed CMS assets and the plugin's committed bundles. After changing frontend source, run `npm exec npm-run-all -- -p 'build:*'` and rerun setup to publish the rebuilt bundles. + +The SQLite database, uploads, generated configuration, and credentials are ignored by Git. Repeated setup preserves existing content. Tests use a separate application directory and do not reset the workbench database. diff --git a/composer.json b/composer.json index dbba790c..6967d41f 100644 --- a/composer.json +++ b/composer.json @@ -46,14 +46,24 @@ }, "autoload-dev": { "psr-4": { - "CraftCms\\Ckeditor\\Tests\\": "tests/" + "CraftCms\\Ckeditor\\Tests\\": "tests/", + "Workbench\\App\\": "workbench/app/", + "Workbench\\Database\\Seeders\\": "workbench/database/seeders/" } }, "scripts": { "check-cs": "pint --test", "fix-cs": "pint", "phpstan": "phpstan --memory-limit=1G", - "tests": "./vendor/bin/pest --compact" + "tests": "./vendor/bin/pest --compact", + "workbench:setup": [ + "@php workbench/prepare.php", + "@php vendor/bin/testbench workbench:build --no-interaction" + ], + "serve": [ + "Composer\\Config::disableProcessTimeout", + "@php vendor/bin/testbench serve --host=127.0.0.1 --port=8125" + ] }, "extra": { "name": "CKEditor", diff --git a/testbench.yaml b/testbench.yaml new file mode 100644 index 00000000..620201a4 --- /dev/null +++ b/testbench.yaml @@ -0,0 +1,22 @@ +laravel: workbench + +providers: + - Workbench\App\Providers\WorkbenchServiceProvider + - Inertia\ServiceProvider + - CraftCms\Aliases\AliasesServiceProvider + - CraftCms\DependencyAwareCache\CacheServiceProvider + - CraftCms\Cms\Providers\CraftServiceProvider + +workbench: + start: /admin + install: false + build: + - vendor-publish: + --tag: craftcms-assets + --force: true + - db-seed: + --class: Workbench\Database\Seeders\DatabaseSeeder + --force: true + discovers: + web: true + config: true diff --git a/workbench/.env.example b/workbench/.env.example new file mode 100644 index 00000000..51ec0fd8 --- /dev/null +++ b/workbench/.env.example @@ -0,0 +1,17 @@ +APP_NAME="CKEditor Workbench" +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost:8125 + +DB_CONNECTION=sqlite +CACHE_STORE=file +SESSION_DRIVER=file +QUEUE_CONNECTION=sync +MAIL_MAILER=log + +WORKBENCH_ADMIN_EMAIL=admin@example.test +WORKBENCH_ADMIN_USERNAME=admin +WORKBENCH_ADMIN_PASSWORD= + +CRAFT_EDITION=pro diff --git a/workbench/.gitignore b/workbench/.gitignore new file mode 100644 index 00000000..4387c76c --- /dev/null +++ b/workbench/.gitignore @@ -0,0 +1,13 @@ +.env +!.env.example +/vendor +/bootstrap/cache +/database/*.sqlite* +/config/craft/project +/config/craft/license.key +/public/vendor +/public/assets +/storage +/cms +/public/cpresources +/public/uploads diff --git a/workbench/app/Providers/WorkbenchServiceProvider.php b/workbench/app/Providers/WorkbenchServiceProvider.php new file mode 100644 index 00000000..dac72ad7 --- /dev/null +++ b/workbench/app/Providers/WorkbenchServiceProvider.php @@ -0,0 +1,30 @@ +app->runningInConsole()) { + $this->app->instance('request', Request::capture()); + } + + $files = new Filesystem; + $root = dirname(__DIR__, levels: 3); + $composer = json_decode($files->get($root.'/composer.json'), associative: true, flags: JSON_THROW_ON_ERROR); + $path = $this->app->basePath('vendor/craftcms/plugins.php'); + $plugins = $files->exists($path) ? (require $path) : []; + $plugins[$composer['name']] = [...$composer['extra'], 'version' => '6.0.0', 'basePath' => $root.'/src']; + + $files->ensureDirectoryExists(dirname($path)); + $files->put($path, 'safeLoad(); +} + +$app = new Application($basePath); +// Keep Testbench's kernels so Laravel does not bootstrap the configuration a second time. +$app->singleton(Kernel::class, Orchestra\Testbench\Console\Kernel::class); +$app->singleton(Illuminate\Contracts\Http\Kernel::class, Orchestra\Testbench\Http\Kernel::class); + +return new ApplicationBuilder($app) + ->withProviders() + ->withMiddleware(static fn (Middleware $middleware) => $middleware->trustProxies(at: ['127.0.0.1', '::1'])) + ->withCommands() + ->create(); diff --git a/workbench/composer.json b/workbench/composer.json new file mode 100644 index 00000000..ef00d89e --- /dev/null +++ b/workbench/composer.json @@ -0,0 +1,9 @@ +{ + "name": "craftcms/ckeditor-workbench", + "type": "project", + "autoload": { + "psr-4": { + "Workbench\\App\\": "app/" + } + } +} diff --git a/workbench/config/filesystems.php b/workbench/config/filesystems.php new file mode 100644 index 00000000..bb7097a1 --- /dev/null +++ b/workbench/config/filesystems.php @@ -0,0 +1,14 @@ + [ + 'images' => [ + 'driver' => 'local', + 'root' => public_path('uploads'), + 'url' => rtrim(env('APP_URL', 'http://localhost:8125'), '/').'/uploads', + 'visibility' => 'public', + ], + ], +]; diff --git a/workbench/config/workbench.php b/workbench/config/workbench.php new file mode 100644 index 00000000..aba4b6eb --- /dev/null +++ b/workbench/config/workbench.php @@ -0,0 +1,9 @@ + env('WORKBENCH_ADMIN_EMAIL', default: 'admin@example.test'), + 'admin_username' => env('WORKBENCH_ADMIN_USERNAME', default: 'admin'), + 'admin_password' => env('WORKBENCH_ADMIN_PASSWORD'), +]; diff --git a/workbench/database/seeders/DatabaseSeeder.php b/workbench/database/seeders/DatabaseSeeder.php new file mode 100644 index 00000000..90b5f6ad --- /dev/null +++ b/workbench/database/seeders/DatabaseSeeder.php @@ -0,0 +1,59 @@ +command->call('craft:install', [ + '--email' => config('workbench.admin_email'), + '--username' => config('workbench.admin_username'), + '--password' => config('workbench.admin_password'), + '--siteName' => 'CKEditor Workbench', + '--siteUrl' => config('app.url'), + '--language' => 'en-US', + '--timezone' => 'UTC', + '--no-interaction' => true, + ]); + + if ($status !== 0) { + throw new RuntimeException('Craft installation failed.'); + } + + Context::forgetHidden('craft.info'); + Context::forgetHidden('craft.isInstalled'); + } + + $site = Sites::getPrimarySite(); + + if ($site->getBaseUrl(false) !== '$APP_URL') { + $site->baseUrl = '$APP_URL'; + + if (! Sites::saveSite($site)) { + throw new RuntimeException('Failed to update the Workbench site URL.'); + } + } + + if (! Plugins::isPluginInstalled('ckeditor')) { + Plugins::installPlugin('ckeditor'); + } + + if (! Plugins::isPluginEnabled('ckeditor')) { + Plugins::enablePlugin('ckeditor'); + } + + Plugins::createPlugin('ckeditor')->publishFrontendAssets(); + $this->call(EditorSeeder::class); + } +} diff --git a/workbench/database/seeders/EditorSeeder.php b/workbench/database/seeders/EditorSeeder.php new file mode 100644 index 00000000..239a403e --- /dev/null +++ b/workbench/database/seeders/EditorSeeder.php @@ -0,0 +1,97 @@ + 'Images', 'handle' => 'images', 'fsHandle' => 'images', 'hasUrls' => true]); + $save(Volumes::saveVolume($volume)); + + $plain = Fields::createField(['type' => PlainText::class, 'name' => 'Summary', 'handle' => 'summary']); + $image = Fields::createField(['type' => Assets::class, 'name' => 'Image', 'handle' => 'image', 'sources' => ['volume:'.$volume->uid], 'defaultUploadLocationSource' => 'volume:'.$volume->uid]); + $save(Fields::saveField($plain)); + $save(Fields::saveField($image)); + + $type = static function (string $name, string $handle, array $fields) use ($save): EntryType { + $type = new EntryType(['name' => $name, 'handle' => $handle]); + $type->setFieldLayout(FieldLayout::createFromConfig([ + 'type' => Entry::class, + 'tabs' => [['name' => 'Content', 'elements' => [ + ['type' => EntryTitleField::class, 'uid' => (string) Str::uuid()], + ...array_map(static fn ($field) => ['type' => CustomField::class, 'uid' => (string) Str::uuid(), 'fieldUid' => $field->uid], $fields), + ]]], + ])); + $save(EntryTypes::saveEntryType($type)); + + return EntryTypes::getEntryTypeById($type->id); + }; + $quote = $type('Quote', 'quote', [$plain]); + $photo = $type('Photo', 'photo', [$image, $plain]); + $body = Fields::createField([ + 'type' => Field::class, 'name' => 'Body', 'handle' => 'body', + 'toolbar' => ['heading', '|', 'bold', 'italic', 'link', 'bulletedList', 'numberedList', 'insertImage', 'createEntry', 'sourceEditing', 'undo', 'redo'], + 'entryTypes' => [['uid' => $quote->uid]], + 'defaultUploadLocationVolume' => $volume->uid, + ]); + $save(Fields::saveField($body)); + $gallery = Fields::createField([ + 'type' => Field::class, 'name' => 'Photo story', 'handle' => 'photoStory', + 'toolbar' => ['bold', 'insertImage', 'createEntry', 'undo', 'redo'], + 'imageMode' => 'entries', + 'entryTypes' => [['uid' => $photo->uid]], + 'imageEntryTypeUid' => $photo->uid, + 'imageFieldUid' => $photo->getFieldLayout()->getCustomFieldElements()[0]->uid, + 'defaultUploadLocationVolume' => $volume->uid, + ]); + $save(Fields::saveField($gallery)); + $article = $type('Article', 'article', [$plain, $body, $gallery]); + $section = new Section([ + 'name' => 'Articles', 'handle' => 'articles', 'type' => SectionType::Channel, + 'entryTypes' => [$article->id], + 'siteSettings' => [new SectionSiteSettings(['siteId' => Sites::getPrimarySite()->id])], + ]); + $save(Sections::saveSection($section)); + $entry = new Entry(['sectionId' => $section->id, 'typeId' => $article->id, 'title' => 'Autumn in Brussels']); + $entry->setFieldValues([ + 'summary' => 'A weekend walk through the city.', + 'body' => '

A walk through Brussels

The autumn market opens on Saturday.

', + 'photoStory' => '

Photographs from the walk.

', + ]); + $save(Elements::saveElement($entry)); + } +} diff --git a/workbench/prepare.php b/workbench/prepare.php new file mode 100644 index 00000000..8fcfa393 --- /dev/null +++ b/workbench/prepare.php @@ -0,0 +1,31 @@ +ensureDirectoryExists(__DIR__.'/'.$directory); +} + +if (! $files->exists(__DIR__.'/.env')) { + $environment = $files->get(__DIR__.'/.env.example'); + $environment = str_replace('APP_KEY=', 'APP_KEY=base64:'.base64_encode(random_bytes(32)), $environment); + $environment = str_replace('WORKBENCH_ADMIN_PASSWORD=', 'WORKBENCH_ADMIN_PASSWORD='.bin2hex(random_bytes(16)), $environment); + $files->put(__DIR__.'/.env', $environment); +} + +if (! $files->exists(__DIR__.'/database/database.sqlite')) { + $files->put(__DIR__.'/database/database.sqlite', ''); +} diff --git a/workbench/public/index.php b/workbench/public/index.php new file mode 100644 index 00000000..4e553052 --- /dev/null +++ b/workbench/public/index.php @@ -0,0 +1,16 @@ +handleRequest(Request::capture()); diff --git a/workbench/routes/web.php b/workbench/routes/web.php new file mode 100644 index 00000000..856aa3a1 --- /dev/null +++ b/workbench/routes/web.php @@ -0,0 +1,7 @@ +