diff --git a/.github/workflows/code-quality.yaml b/.github/workflows/code-quality.yaml new file mode 100644 index 0000000..5f0e1e5 --- /dev/null +++ b/.github/workflows/code-quality.yaml @@ -0,0 +1,37 @@ +name: Code Quality + +on: + pull_request: + push: + branches: + - main + +jobs: + codeQuality: + runs-on: ubuntu-latest + name: PHP + steps: + - name: Cancel previous incomplete runs + uses: styfle/cancel-workflow-action@0.12.1 + with: + access_token: ${{ github.token }} + + - name: Checkout changes + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install PHP and PHP Code Sniffer + uses: shivammathur/setup-php@v2 + with: + php-version: 8.2 + extensions: curl, fileinfo, gd, mbstring, openssl, pdo, pdo_sqlite, sqlite3, xml, zip + tools: phpcs + + - name: Run code quality checks (on push) + if: github.event_name == 'push' + run: ./.github/workflows/utilities/phpcs-push ${{ github.sha }} + + - name: Run code quality checks (on pull request) + if: github.event_name == 'pull_request' + run: ./.github/workflows/utilities/phpcs-pr ${{ github.base_ref }} diff --git a/.github/workflows/utilities/phpcs-pr b/.github/workflows/utilities/phpcs-pr new file mode 100755 index 0000000..b997066 --- /dev/null +++ b/.github/workflows/utilities/phpcs-pr @@ -0,0 +1,83 @@ +#!/usr/bin/env php + ($line[3] === 'warning'), + 'message' => $line[4], + 'line' => $line[1], + ]; + } + + // Render report + echo "\e[0;31mFound " + . ((count($lines) === 1) + ? '1 issue' + : count($lines) . ' issues') + . " with code quality.\e[0m"; + echo "\n"; + + foreach ($files as $file => $errors) { + echo "\n"; + echo "\e[1;37m" . str_replace('"', '', $file) . "\e[0m"; + echo "\n\n"; + + foreach ($errors as $error) { + echo "\e[2m" . str_pad(' L' . $error['line'], 7) . " | \e[0m"; + if ($error['warning'] === false) { + echo "\e[0;31mERR:\e[0m "; + } else { + echo "\e[1;33mWARN:\e[0m "; + } + echo $error['message']; + echo "\n"; + } + } + exit(1); +} diff --git a/.github/workflows/utilities/phpcs-push b/.github/workflows/utilities/phpcs-push new file mode 100755 index 0000000..add55df --- /dev/null +++ b/.github/workflows/utilities/phpcs-push @@ -0,0 +1,83 @@ +#!/usr/bin/env php + ($line[3] === 'warning'), + 'message' => $line[4], + 'line' => $line[1], + ]; + } + + // Render report + echo "\e[0;31mFound " + . ((count($lines) === 1) + ? '1 issue' + : count($lines) . ' issues') + . " with code quality.\e[0m"; + echo "\n"; + + foreach ($files as $file => $errors) { + echo "\n"; + echo "\e[1;37m" . str_replace('"', '', $file) . "\e[0m"; + echo "\n\n"; + + foreach ($errors as $error) { + echo "\e[2m" . str_pad(' L' . $error['line'], 7) . " | \e[0m"; + if ($error['warning'] === false) { + echo "\e[0;31mERR:\e[0m "; + } else { + echo "\e[1;33mWARN:\e[0m "; + } + echo $error['message']; + echo "\n"; + } + } + exit(1); +} diff --git a/Plugin.php b/Plugin.php index cb866e7..2d649ea 100644 --- a/Plugin.php +++ b/Plugin.php @@ -10,6 +10,7 @@ use Winter\Blocks\Classes\BlockManager; use Winter\Blocks\Classes\BlocksDatasource; use Winter\Blocks\Classes\Block as BlockModel; +use Winter\Blocks\Console\ScaffoldCommand; use Winter\Blocks\FormWidgets\Block; /** @@ -110,6 +111,8 @@ public function boot(): void */ public function register(): void { + $this->registerConsoleCommand('winter.blocks.scaffold', ScaffoldCommand::class); + Event::listen('cms.theme.registerHalcyonDatasource', function (Theme $theme, $resolver) { BlockManager::instance(); // moved here diff --git a/console/ScaffoldCommand.php b/console/ScaffoldCommand.php new file mode 100644 index 0000000..0d2b4eb --- /dev/null +++ b/console/ScaffoldCommand.php @@ -0,0 +1,435 @@ +getLaravel()->environment('production')) { + $this->error('scaffold:winter.blocks cannot run in the production environment.'); + + return self::FAILURE; + } + + if (!class_exists(Page::class)) { + $this->error('Winter.Pages is required to scaffold Blocks demo content (it hosts the blocks form widget). Install/enable winter/wn-pages-plugin and retry.'); + + return self::FAILURE; + } + + $theme = Theme::getEditTheme(); + if (!$theme) { + $this->error('No editable theme is active; cannot scaffold static pages.'); + + return self::FAILURE; + } + + if ($this->option('fresh')) { + $this->deleteExisting($theme); + } + + if ($this->scaffoldExists($theme)) { + $this->warn('Blocks scaffold content already exists. Use --fresh to recreate it.'); + + return self::SUCCESS; + } + + $this->createLayout($theme); + $this->copyMediaImage(); + + $count = $this->createPages($theme); + $this->info("Created demo layout '" . self::LAYOUT . "' and {$count} static page(s) with populated blocks."); + + $this->newLine(); + $this->line('Pages list: ' . Backend::url('winter/pages')); + $this->line('Edit a page: open the Pages list above and pick a "Scaffold Blocks" page to open the blocks editor.'); + + return self::SUCCESS; + } + + /** + * Whether any scaffold pages already exist in the theme. + */ + protected function scaffoldExists(Theme $theme): bool + { + foreach (Page::listInTheme($theme, true) as $page) { + if (str_starts_with($page->getBaseFileName(), self::PREFIX)) { + return true; + } + } + + return false; + } + + /** + * Remove previously scaffolded pages, the demo layout and the copied media file. + */ + protected function deleteExisting(Theme $theme): void + { + $removed = 0; + foreach (Page::listInTheme($theme, true) as $page) { + if (str_starts_with($page->getBaseFileName(), self::PREFIX)) { + $page->delete(); + $removed++; + } + } + + $layout = Layout::load($theme, self::LAYOUT); + if ($layout) { + $layout->delete(); + } + + try { + if (MediaLibrary::instance()->exists(self::MEDIA_IMAGE)) { + MediaLibrary::instance()->deleteFiles([self::MEDIA_IMAGE]); + } + } catch (\Throwable $e) { + // Best-effort; media may already be gone. + } + + if ($removed > 0 || $layout) { + $this->info("Removed {$removed} scaffold page(s) and the demo layout."); + } + } + + /** + * Create the demo layout that declares a `blocks` (and helper `subtitle`) + * variable in its markup body, so the Pages editor renders the blocks widget. + */ + protected function createLayout(Theme $theme): void + { + $markup = <<<'TWIG' +{variable type="text" name="subtitle" label="Subtitle" tab="Content" placement="primary"}{/variable} +{variable type="blocks" name="blocks" tags="pages" tab="Content"}{/variable} + + + + + {{ this.page.title }} + {% styles %} + + +

{{ this.page.title }}

+ {% if subtitle %}

{{ subtitle }}

{% endif %} + {{ renderBlocks(blocks) }} + {% page %} + {% scripts %} + + +TWIG; + + $layout = Layout::load($theme, self::LAYOUT) ?: Layout::inTheme($theme); + $layout->fileName = self::LAYOUT; + $layout->settings = ['description' => 'Scaffold: Blocks demo layout']; + $layout->markup = $markup; + $layout->save(); + } + + /** + * Copy a bundled image into the media library so image/video blocks resolve to + * a real file. Guarded by File::exists; silently skips if no source is present. + */ + protected function copyMediaImage(): void + { + $sources = [ + base_path('themes/demo/assets/images/winter.png'), + base_path('themes/demo/assets/images/theme-preview.png'), + base_path('modules/backend/assets/images/wordmark.png'), + ]; + + foreach ($sources as $source) { + if (!File::exists($source)) { + continue; + } + + MediaLibrary::instance()->put(self::MEDIA_IMAGE, File::get($source)); + MediaLibrary::instance()->resetCache(); + + return; + } + } + + /** + * The media path referenced by image/video blocks. Falls back to an existing + * media file if the copy above failed, and finally to an empty string. + */ + protected function imagePath(): string + { + if (MediaLibrary::instance()->exists(self::MEDIA_IMAGE)) { + return self::MEDIA_IMAGE; + } + + foreach (['/sticker-1.png', '/Sticker.png'] as $candidate) { + if (MediaLibrary::instance()->exists($candidate)) { + return $candidate; + } + } + + return ''; + } + + /** + * Create the spread of static pages. Returns the number of pages created. + */ + protected function createPages(Theme $theme): int + { + $image = $this->imagePath(); + $count = 0; + + // 1. Kitchen-sink page: every block type, including nested/container blocks + // and deliberately long content, so the editor and every block field is + // exercised in one place. + $this->makePage($theme, 'kitchen-sink', 'Scaffold Blocks: Kitchen Sink', [ + 'subtitle' => 'Every shippable block type on a single, deliberately long page.', + 'blocks' => $this->kitchenSinkBlocks($image), + ]); + $count++; + + // 2. Long-content page: one very long richtext + many title/plaintext blocks + // to stress the blocks list height, scrolling and reordering. + $this->makePage($theme, 'long-content', 'Scaffold Blocks: Long Content', [ + 'subtitle' => 'A long stack of content blocks.', + 'blocks' => $this->longContentBlocks(), + ]); + $count++; + + // 3. Media page: image, video, youtube and vimeo blocks together. + $this->makePage($theme, 'media', 'Scaffold Blocks: Media', [ + 'subtitle' => 'Image and embedded-video blocks.', + 'blocks' => [ + $this->block('image', ['image' => $image, 'alt_text' => 'A scaffolded demo image', 'size' => 'w-2/3']), + $this->block('video', ['video' => $image]), + $this->block('youtube', ['youtube_id' => 'dQw4w9WgXcQ']), + $this->block('vimeo', ['vimeo_id' => '76979871']), + ], + ]); + $count++; + + // 4. Container page: columns_two, cards and button_group (blocks-in-blocks). + $this->makePage($theme, 'containers', 'Scaffold Blocks: Containers', [ + 'subtitle' => 'Container blocks that nest other blocks.', + 'blocks' => [ + $this->block('columns_two', [ + 'left' => [ + $this->block('title', ['content' => 'Left column', 'size' => 'h3', 'alignment_x' => 'left']), + $this->block('richtext', ['content' => '

Left column rich text content.

']), + ], + 'right' => [ + $this->block('title', ['content' => 'Right column', 'size' => 'h3', 'alignment_x' => 'left']), + $this->block('plaintext', ['content' => 'Right column plain text content.']), + ], + ]), + $this->block('cards', [ + 'cards' => [ + ['blocks' => [ + $this->block('image', ['image' => $image, 'alt_text' => 'Card one', 'size' => 'w-full']), + $this->block('richtext', ['content' => '

Card one body.

']), + ]], + ['blocks' => [ + $this->block('richtext', ['content' => '

Card two body, no image.

']), + ]], + ['blocks' => [ + $this->block('richtext', ['content' => '

Card three body.

']), + $this->block('button', $this->buttonConfig('Learn more')), + ]], + ], + ]), + $this->block('button_group', [ + 'position' => 'justify-center', + 'width' => 'w-auto', + 'buttons' => [ + $this->block('button', $this->buttonConfig('Primary')), + $this->block('button', $this->buttonConfig('Secondary')), + ], + ]), + ], + ]); + $count++; + + // 5. Empty page: no blocks — exercises the widget's empty state. + $this->makePage($theme, 'empty', 'Scaffold Blocks: Empty State', [ + 'subtitle' => 'No blocks yet — shows the empty blocks editor.', + 'blocks' => [], + ]); + $count++; + + // 6-... Filler pages to populate the Pages tree/list. + for ($i = 1; $i <= 12; $i++) { + $this->makePage($theme, 'filler-' . str_pad((string) $i, 2, '0', STR_PAD_LEFT), "Scaffold Blocks: Sample Page {$i}", [ + 'subtitle' => "Filler page #{$i}.", + 'blocks' => [ + $this->block('title', ['content' => "Sample page {$i}", 'size' => 'h2', 'alignment_x' => 'center']), + $this->block('plaintext', ['content' => "Scaffolded filler content for sample page {$i}."]), + $this->block('divider'), + $this->block('richtext', ['content' => "

Filler rich text for page {$i}.

"]), + ], + ]); + $count++; + } + + return $count; + } + + /** + * Persist a static page using the blocks-enabled demo layout. + */ + protected function makePage(Theme $theme, string $slug, string $title, array $vars): Page + { + $fileName = self::PREFIX . '-' . $slug; + + $page = Page::load($theme, $fileName . '.htm') ?: Page::inTheme($theme); + $page->fileName = $fileName; + $page->fill([ + 'settings' => [ + 'viewBag' => array_merge([ + 'title' => $title, + 'url' => '/' . $fileName, + 'layout' => self::LAYOUT, + 'is_hidden' => 0, + 'navigation_hidden' => 0, + ], $vars), + ], + 'markup' => '', + ]); + $page->save(); + + return $page; + } + + /** + * Build a single block entry: a `_group` key plus its field/config values, + * matching the shape the blocks form widget stores and `renderBlock` expects. + */ + protected function block(string $group, array $data = []): array + { + return array_merge(['_group' => $group], $data); + } + + /** + * Config payload for a button block (its fields live under a nestedform). + */ + protected function buttonConfig(string $label): array + { + return [ + 'config' => [ + 'label' => $label, + 'color' => '#1f6feb', + 'actions' => [], + ], + ]; + } + + /** + * The all-block-types payload for the kitchen-sink page. + */ + protected function kitchenSinkBlocks(string $image): array + { + $long = 'This is a long paragraph of rich text used to give the blocks editor ' + . 'something substantial to render. ' . str_repeat( + 'It repeats a sentence several times so the block grows tall enough to ' + . 'exercise scrolling, wrapping and reordering within the blocks widget. ', + 6 + ); + + return [ + $this->block('title', ['content' => 'The complete block showcase', 'size' => 'h2', 'alignment_x' => 'center']), + $this->block('plaintext', ['content' => 'A short plain-text intro beneath the title.']), + $this->block('divider'), + $this->block('richtext', ['content' => '

' . $long . '

']), + $this->block('image', ['image' => $image, 'alt_text' => 'Kitchen sink image', 'size' => 'w-1/2']), + $this->block('code', ['content' => "
\n

Some raw HTML code block.

\n
"]), + $this->block('button', $this->buttonConfig('A call to action')), + $this->block('button_group', [ + 'position' => 'justify-start', + 'width' => 'w-auto', + 'buttons' => [ + $this->block('button', $this->buttonConfig('First')), + $this->block('button', $this->buttonConfig('Second')), + ], + ]), + $this->block('columns_two', [ + 'left' => [$this->block('richtext', ['content' => '

Left side content.

'])], + 'right' => [$this->block('richtext', ['content' => '

Right side content.

'])], + ]), + $this->block('video', ['video' => $image]), + $this->block('youtube', ['youtube_id' => 'dQw4w9WgXcQ']), + $this->block('vimeo', ['vimeo_id' => '76979871']), + ]; + } + + /** + * A long vertical stack of content blocks for the long-content page. + */ + protected function longContentBlocks(): array + { + $blocks = [ + $this->block('title', ['content' => 'A long stack of content', 'size' => 'h2', 'alignment_x' => 'left']), + ]; + + for ($i = 1; $i <= 15; $i++) { + $blocks[] = $this->block('title', ['content' => "Section {$i}", 'size' => 'h3', 'alignment_x' => 'left']); + $blocks[] = $this->block('richtext', [ + 'content' => "

Body copy for section {$i}. " + . str_repeat('Some filler sentence to add length. ', 4) . '

', + ]); + $blocks[] = $this->block('divider'); + } + + return $blocks; + } +} diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..e9aac5a --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,97 @@ + + + The coding standard for Winter CMS Plugins. + + + + + + + + + + + + + + + + + + + + */behaviors/*/partials/*\.php + */components/*/*\.php + */controllers/*/*\.php + */formwidgets/*/partials/*\.php + */reportwidgets/*/partials/*\.php + */widgets/*/partials/*\.php + */partials/*\.php + + + + + */updates/*\.php + */tests/* + + + + + */tests/* + + + + + */behaviors/*/partials/*\.php + */components/*/*\.php + */controllers/*/*\.php + */formwidgets/*/partials/*\.php + */reportwidgets/*/partials/*\.php + */widgets/*/partials/*\.php + */partials/*\.php + + + + + */behaviors/*/partials/*\.php + */components/*/*\.php + */controllers/*/*\.php + */formwidgets/*/partials/*\.php + */reportwidgets/*/partials/*\.php + */widgets/*/partials/*\.php + */partials/*\.php + + + + */behaviors/*/partials/*\.php + */components/*/*\.php + */controllers/*/*\.php + */formwidgets/*/partials/*\.php + */reportwidgets/*/partials/*\.php + */widgets/*/partials/*\.php + */partials/*\.php + + + + + + + . + */assets/* + */vendor/* + */node_modules/* + diff --git a/tests/console/ScaffoldCommandTest.php b/tests/console/ScaffoldCommandTest.php new file mode 100644 index 0000000..24169e5 --- /dev/null +++ b/tests/console/ScaffoldCommandTest.php @@ -0,0 +1,47 @@ +app->make(ConsoleKernel::class)->registerCommand(new ScaffoldCommand()); + } + + public function testCommandIsRegistered() + { + $this->assertArrayHasKey('scaffold:winter.blocks', Artisan::all()); + } + + public function testRefusesToRunInProduction() + { + $this->app['env'] = 'production'; + + $exitCode = Artisan::call('scaffold:winter.blocks'); + + $this->assertSame(1, $exitCode); + $this->assertStringContainsString('production', Artisan::output()); + + $this->app['env'] = 'testing'; + } +}