diff --git a/README.MD b/README.MD
index 9ccd07f..9c8da00 100644
--- a/README.MD
+++ b/README.MD
@@ -8,6 +8,153 @@ A WordPress plugin with a collection of blocks and features for the Gutenberg ed
2. Run `npm install` to install dependencies
3. Run `npm start` for local development
+## 🧱 PHP-only blocks
+
+Blocks that need no JavaScript live in `src/PhpBlocks/` instead of `src/Blocks/`. They are
+registered straight from source and never pass through webpack, so **they need no
+`npm run build`** — adding the files below is enough.
+
+```
+src/PhpBlocks/Greeting/
+├── block.json "name": "yard/greeting"
+├── greeting.blade.php named after the slug in block.json
+└── Greeting.php optional; the block's logic
+```
+
+The folder is PascalCase because it doubles as a PHP namespace segment. The block's
+identity comes from `block.json`'s `name`, not from the folder: the part after the `/` is
+the slug, which names the Blade template and is the key a site filters on through
+[`yard::gutenberg/allowed-blocks`](#yardgutenbergallowed-blocks). So a folder `OpeningHours`
+with `"name": "yard/opening-hours"` looks for `opening-hours.blade.php`.
+
+`block.json` must set `supports.autoRegister`. That is what makes WordPress list the block
+in the inserter and preview it with core's `ServerSideRender`, without any client-side
+registration:
+
+```json
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "yard/greeting",
+ "version": "0.1.0",
+ "title": "Begroeting",
+ "category": "yard",
+ "icon": "smiley",
+ "description": "Toont een begroeting.",
+ "textdomain": "yard",
+ "supports": {
+ "autoRegister": true,
+ "html": false
+ }
+}
+```
+
+Every template receives these variables:
+
+| Variable | Description |
+| -------------------- | -------------------------------------------------------------------- |
+| `$wrapperAttributes` | Output of `get_block_wrapper_attributes()`. Print it with `{!! !!}`. |
+| `$attributes` | The block's attributes, with the defaults from `block.json` merged. |
+| `$content` | Inner content, for blocks that have any. |
+| `$block` | The `WP_Block` instance. |
+| `$blockClass` | The block's generated class, e.g. `wp-block-yard-greeting`. |
+
+```blade
+
+ {{ $greeting }}
+
+```
+
+Attributes typed `string`, `number`, `integer`, `boolean` or `enum` get an inspector
+control generated for them automatically — no `edit` component to write.
+
+### View models
+
+To keep logic out of the template, add a class in the block's folder named after the
+folder, extending `BlockViewModel`. It is picked up automatically; a block without one is
+rendered by a plain `BlockViewModel`, which adds nothing.
+
+```php
+namespace Yard\Gutenberg\PhpBlocks\Greeting;
+
+use Yard\Gutenberg\PhpBlocks\BlockViewModel;
+
+class Greeting extends BlockViewModel
+{
+ public function with(): array
+ {
+ return ['greeting' => $this->greeting()];
+ }
+
+ public function classes(): array
+ {
+ return [$this->modifier('morning')];
+ }
+
+ private function greeting(): string
+ {
+ // $this->attributes(), $this->attribute('name', $default),
+ // $this->content(), $this->block(), $this->blockClass()
+ }
+}
+```
+
+| Method | Purpose |
+| ------------ | ------------------------------------------------------------------------------ |
+| `with()` | Data for the template. |
+| `override()` | Data for the template that wins over everything else. |
+| `classes()` | Extra classes for the wrapper element. |
+
+The merge order is `with()` → the variables in the table above → `override()`. That is
+deliberately the same shape and precedence as `Roots\Acorn\View\Composer`, so the class
+reads like the composers in a Sage theme.
+
+`modifier('morning')` returns `wp-block-yard-greeting--morning`. Build modifiers with it
+rather than writing the base class by hand: it derives from `$blockClass`, so it follows
+the `block_default_classname` filter, and it runs the slug through
+`sanitize_html_class()`.
+
+An actual Acorn view composer cannot serve these blocks, so don't reach for one: composers
+are matched on the *view name*, and rendering a template by absolute path through
+`Illuminate\View\Factory::file()` uses that path as the view name. A composer registered
+for `blocks.greeting` never fires.
+
+### Class names
+
+`wp-block-yard-` is generated by WordPress, not by this plugin —
+`wp_get_block_default_classname()` turns `yard/greeting` into `wp-block-yard-greeting`,
+and the `generated-classname` block support puts it on the wrapper. It is the BEM block
+that `src/Blocks/*/style.scss` already builds on:
+
+```scss
+$block: 'wp-block-yard-greeting';
+
+.#{$block} {
+ &--morning { /* from classes() */ }
+ &__text { /* from the template */ }
+}
+```
+
+- Classes from `classes()` are **merged ahead** of the generated one by
+ `get_block_wrapper_attributes()`, and de-duplicated. The same call also concatenates
+ `style`, lets an explicit `id` or `aria-label` win, and passes any other attribute
+ through untouched.
+- `"supports": { "className": false }` in a `block.json` drops the generated class.
+- The `block_default_classname` filter renames it globally; `$blockClass` and every
+ `modifier()` follow along.
+
+### Notes
+
+- **Requires WordPress 7.0+.** On older versions these blocks still render existing
+ content, they just don't appear in the inserter.
+- Blade is rendered through the **host theme's** view engine (Sage/Acorn); this plugin
+ ships none. See `src/PhpBlocks/BladeRenderer.php`.
+- `autoRegister` supports no `InnerBlocks`, media pickers or inline rich text. A block
+ needing those belongs in `src/Blocks/` with a real `edit` component.
+- Anything time- or request-dependent (such as `yard/greeting`) is frozen by a full-page
+ cache at whatever value the page was cached with. A site that needs it accurate has to
+ exclude the page from caching or render that part client-side.
+
## 🚀 Release
1. Update plugin versions and run `npm run build` to build assets. Commit and push to remote.
@@ -25,6 +172,7 @@ add_filter('yard::gutenberg/allowed-blocks', fn () => [
'collapse-item',
'counting-number',
'facetwp',
+ 'greeting',
'icon',
'iconlist',
'iconlist-item',
@@ -59,6 +207,25 @@ public function registerCoreBlocks($initialAllowedBlocks): array
}
```
+### `yard::gutenberg/greeting-periods`
+
+Change the greeting the `yard/greeting` block shows, and when. The array key is the BEM
+modifier put on the block's wrapper (`morning` renders
+`wp-block-yard-greeting--morning`), `from` is the hour the period starts in the site's
+timezone, and `greeting` is the text.
+
+```PHP
+add_filter('yard::gutenberg/greeting-periods', fn () => [
+ 'morning' => ['from' => 6, 'greeting' => 'Goedemorgen,'],
+ 'afternoon' => ['from' => 12, 'greeting' => 'Goedemiddag,'],
+ 'evening' => ['from' => 18, 'greeting' => 'Goedenavond,'],
+]);
+```
+
+Any number of periods works and the map need not start at hour 0 — the day wraps, so the
+example above leaves the small hours to `evening`. The defaults are `night` from 0,
+`morning` from 6, `afternoon` from 12 and `evening` from 18.
+
### `yard::gutenberg/allowed-blocks-whitelisted-prefixes`
By default, all blocks are registered. Use this filter to register only the allowed blocks with a specific prefix. The example adds the `tribe` prefix:
diff --git a/package-lock.json b/package-lock.json
index 8119eba..a38614c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "yard-gutenberg",
- "version": "1.6.2",
+ "version": "1.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "yard-gutenberg",
- "version": "1.6.2",
+ "version": "1.8.0",
"license": "MIT License",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
diff --git a/readme.txt b/readme.txt
index 6992e75..959a374 100644
--- a/readme.txt
+++ b/readme.txt
@@ -1,7 +1,7 @@
=== Yard | Gutenberg ===
Contributors: Yard | Digital Agency
Tags: block
-Tested up to: 6.7
+Tested up to: 7.0
Stable tag: 1.8.0
License: MIT License
License URI: https://opensource.org/licenses/MIT
diff --git a/src/PhpBlocks/BladeRenderer.php b/src/PhpBlocks/BladeRenderer.php
new file mode 100644
index 0000000..afffbfa
--- /dev/null
+++ b/src/PhpBlocks/BladeRenderer.php
@@ -0,0 +1,118 @@
+
+ */
+ private static $reported = [];
+
+ /**
+ * Render a Blade template.
+ *
+ * @param string $templatePath Absolute path to a `.blade.php` file.
+ * @param array $data Variables made available to the template.
+ */
+ public function render(string $templatePath, array $data = []): string
+ {
+ if (! file_exists($templatePath)) {
+ return $this->fallback($templatePath, sprintf('The Blade template "%s" does not exist.', $templatePath));
+ }
+
+ $factory = $this->viewFactory();
+
+ if (null === $factory) {
+ return $this->fallback($templatePath, 'No Blade view engine is available. PHP-only blocks are rendered through the view engine of a Sage/Acorn theme.');
+ }
+
+ return (string) $factory->file($templatePath, $data);
+ }
+
+ /**
+ * Resolve the theme's view factory, or null when there isn't one.
+ *
+ * `app()` is Acorn's own global helper, `Roots\app()` its deprecated
+ * predecessor, and `view()` without arguments returns the factory in both
+ * Acorn and Laravel. Resolving from the container can throw when the view
+ * service provider was never registered, hence the try/catch.
+ *
+ * @return object|null An `Illuminate\View\Factory`-like object.
+ */
+ private function viewFactory(): ?object
+ {
+ foreach (['app', 'Roots\\app'] as $container) {
+ if (! function_exists($container)) {
+ continue;
+ }
+
+ try {
+ $factory = $container('view');
+ } catch (\Throwable $e) {
+ continue;
+ }
+
+ if ($this->isViewFactory($factory)) {
+ return $factory;
+ }
+ }
+
+ if (function_exists('view')) {
+ try {
+ $factory = \view();
+ } catch (\Throwable $e) {
+ return null;
+ }
+
+ if ($this->isViewFactory($factory)) {
+ return $factory;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * @param mixed $factory
+ */
+ private function isViewFactory($factory): bool
+ {
+ return is_object($factory) && method_exists($factory, 'file');
+ }
+
+ /**
+ * Handle a template that can't be rendered.
+ *
+ * The editor previews these blocks over the REST block-renderer endpoint, so
+ * there we return a visible hint. On the front end we stay silent: a theme
+ * without a view engine shouldn't leak diagnostics to visitors.
+ */
+ private function fallback(string $templatePath, string $message): string
+ {
+ if (! isset(self::$reported[$templatePath])) {
+ self::$reported[$templatePath] = true;
+
+ \_doing_it_wrong(__METHOD__, \esc_html($message), '1.8.0');
+ }
+
+ if (defined('REST_REQUEST') && REST_REQUEST) {
+ return '' . \esc_html__('Dit blok kan niet worden weergegeven: er is geen Blade-templateengine beschikbaar.', 'yard-gutenberg') . '
';
+ }
+
+ return '';
+ }
+}
diff --git a/src/PhpBlocks/BlockViewModel.php b/src/PhpBlocks/BlockViewModel.php
new file mode 100644
index 0000000..2ad8320
--- /dev/null
+++ b/src/PhpBlocks/BlockViewModel.php
@@ -0,0 +1,166 @@
+ */
+ private $attributes = [];
+
+ /** @var string */
+ private $content = '';
+
+ /** @var \WP_Block|null */
+ private $block;
+
+ /**
+ * The block's generated class, e.g. `wp-block-yard-greeting`.
+ *
+ * @var string
+ */
+ private $blockClass = '';
+
+ /** @var string */
+ private $wrapperAttributes = '';
+
+ /**
+ * Assemble the data the Blade template is rendered with.
+ *
+ * Following Acorn: `with()` supplies defaults, the block's own render data
+ * beats them, and `override()` beats everything — which is how a block
+ * replaces something it is given, such as `wrapperAttributes`.
+ *
+ * @param array $attributes Attributes, with `block.json` defaults merged in.
+ *
+ * @return array
+ */
+ final public function compose(array $attributes, string $content, ?\WP_Block $block, string $blockClass): array
+ {
+ $this->attributes = $attributes;
+ $this->content = $content;
+ $this->block = $block;
+ $this->blockClass = $blockClass;
+
+ // An empty class is harmless: core splits it with PREG_SPLIT_NO_EMPTY,
+ // so it contributes nothing to the merge.
+ $this->wrapperAttributes = \get_block_wrapper_attributes([
+ 'class' => implode(' ', array_filter($this->classes())),
+ ]);
+
+ $data = [
+ 'attributes' => $attributes,
+ 'content' => $content,
+ 'block' => $block,
+ 'blockClass' => $blockClass,
+ 'wrapperAttributes' => $this->wrapperAttributes,
+ ];
+
+ return array_merge($this->with(), $data, $this->override());
+ }
+
+ /**
+ * Data passed to the Blade template.
+ *
+ * @return array
+ */
+ public function with(): array
+ {
+ return [];
+ }
+
+ /**
+ * Data passed to the Blade template, winning over everything else.
+ *
+ * @return array
+ */
+ public function override(): array
+ {
+ return [];
+ }
+
+ /**
+ * Extra classes for the block's wrapper element.
+ *
+ * `get_block_wrapper_attributes()` merges these ahead of the class
+ * WordPress generates, and de-duplicates.
+ *
+ * @return string[]
+ */
+ public function classes(): array
+ {
+ return [];
+ }
+
+ /**
+ * A BEM modifier on the block's generated class.
+ *
+ * Built from `$blockClass` rather than a literal so it follows the
+ * `block_default_classname` filter, and matches the `$block` variable the
+ * block's SCSS uses.
+ */
+ protected function modifier(string $name): string
+ {
+ $name = \sanitize_html_class($name);
+
+ return '' === $this->blockClass || '' === $name ? '' : $this->blockClass . '--' . $name;
+ }
+
+ /**
+ * The block's attributes, with the defaults from `block.json` already merged
+ * in by `WP_Block_Type::prepare_attributes_for_render()`.
+ *
+ * @return array
+ */
+ protected function attributes(): array
+ {
+ return $this->attributes;
+ }
+
+ /**
+ * @param mixed $default
+ *
+ * @return mixed
+ */
+ protected function attribute(string $name, $default = null)
+ {
+ return array_key_exists($name, $this->attributes) ? $this->attributes[$name] : $default;
+ }
+
+ protected function content(): string
+ {
+ return $this->content;
+ }
+
+ protected function block(): ?\WP_Block
+ {
+ return $this->block;
+ }
+
+ protected function blockClass(): string
+ {
+ return $this->blockClass;
+ }
+
+ protected function wrapperAttributes(): string
+ {
+ return $this->wrapperAttributes;
+ }
+}
diff --git a/src/PhpBlocks/Greeting/Greeting.php b/src/PhpBlocks/Greeting/Greeting.php
new file mode 100644
index 0000000..8b79952
--- /dev/null
+++ b/src/PhpBlocks/Greeting/Greeting.php
@@ -0,0 +1,118 @@
+ $this->period()['greeting'],
+ ];
+ }
+
+ public function classes(): array
+ {
+ return array_filter([$this->modifier($this->period()['slug'])]);
+ }
+
+ /**
+ * The period covering the current hour in the site's timezone.
+ *
+ * `current_datetime()` is already in `wp_timezone()`, so this follows the
+ * site's clock rather than the server's.
+ *
+ * @return array{slug: string, greeting: string}
+ */
+ private function period(): array
+ {
+ if (null !== $this->period) {
+ return $this->period;
+ }
+
+ $periods = $this->periods();
+
+ if ([] === $periods) {
+ return $this->period = ['slug' => '', 'greeting' => ''];
+ }
+
+ $hour = (int) \current_datetime()->format('G');
+
+ // The day wraps: hours before the first period belong to the last one.
+ $slugs = array_keys($periods);
+ $slug = end($slugs);
+
+ foreach ($periods as $candidate => $period) {
+ if ($period['from'] <= $hour) {
+ $slug = $candidate;
+ }
+ }
+
+ return $this->period = [
+ 'slug' => (string) $slug,
+ 'greeting' => $periods[$slug]['greeting'],
+ ];
+ }
+
+ /**
+ * The greeting periods, keyed by slug and sorted by start hour.
+ *
+ * The key doubles as the BEM modifier on the block's class. A site may return
+ * any number of periods and need not start at hour 0 — `period()` wraps
+ * around, so a map starting at 6 leaves the small hours to the final period.
+ *
+ * @return array
+ */
+ private function periods(): array
+ {
+ /**
+ * Filter the greeting periods.
+ *
+ * Keyed by slug, which becomes the BEM modifier on the block's class,
+ * e.g. `morning` renders `wp-block-yard-greeting--morning`.
+ *
+ * @param array $periods
+ */
+ $periods = \apply_filters('yard::gutenberg/greeting-periods', [
+ 'night' => ['from' => 0, 'greeting' => \__('Goedenacht', 'yard-gutenberg')],
+ 'morning' => ['from' => 6, 'greeting' => \__('Goedemorgen', 'yard-gutenberg')],
+ 'afternoon' => ['from' => 12, 'greeting' => \__('Goedemiddag', 'yard-gutenberg')],
+ 'evening' => ['from' => 18, 'greeting' => \__('Goedenavond', 'yard-gutenberg')],
+ ]);
+
+ if (! is_array($periods)) {
+ return [];
+ }
+
+ $normalized = [];
+
+ foreach ($periods as $slug => $period) {
+ if (! is_array($period) || ! isset($period['from']) || ! isset($period['greeting'])) {
+ continue;
+ }
+
+ $normalized[(string) $slug] = [
+ 'from' => (int) $period['from'],
+ 'greeting' => (string) $period['greeting'],
+ ];
+ }
+
+ uasort($normalized, function (array $a, array $b) {
+ return $a['from'] <=> $b['from'];
+ });
+
+ return $normalized;
+ }
+}
diff --git a/src/PhpBlocks/Greeting/block.json b/src/PhpBlocks/Greeting/block.json
new file mode 100644
index 0000000..72f9e67
--- /dev/null
+++ b/src/PhpBlocks/Greeting/block.json
@@ -0,0 +1,24 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "yard/greeting",
+ "version": "0.1.0",
+ "title": "Begroeting",
+ "category": "yard",
+ "icon": "smiley",
+ "description": "Toont een begroeting.",
+ "textdomain": "yard",
+ "supports": {
+ "align": true,
+ "autoRegister": true,
+ "html": false
+ },
+ "attributes": {
+ "suffix": {
+ "label": "Tekst na de begroeting",
+ "type": "string",
+ "default": ""
+ }
+ },
+ "style": "file:./style.css"
+}
diff --git a/src/PhpBlocks/Greeting/greeting.blade.php b/src/PhpBlocks/Greeting/greeting.blade.php
new file mode 100644
index 0000000..ffc8ff6
--- /dev/null
+++ b/src/PhpBlocks/Greeting/greeting.blade.php
@@ -0,0 +1,8 @@
+
+ @if (filled($attributes['suffix']))
+ {{ $greeting }},
+ {{ $attributes['suffix'] }}
+ @else
+ {{ $greeting }}
+ @endif
+
diff --git a/src/PhpBlocks/Greeting/style.css b/src/PhpBlocks/Greeting/style.css
new file mode 100644
index 0000000..2493c0f
--- /dev/null
+++ b/src/PhpBlocks/Greeting/style.css
@@ -0,0 +1,19 @@
+@layer plugins {
+ .wp-block-yard-greeting {
+ color: var( --yard-greeting-color, inherit );
+ font-size: var( --yard-greeting-font-size, inherit );
+ line-height: var( --yard-greeting-line-height, inherit );
+
+ &:has(.wp-block-yard-greeting__suffix) {
+ display: flex;
+ flex-direction: column;
+ row-gap: var( --yard-greeting-spacing, 0 );
+ }
+ }
+
+ .wp-block-yard-greeting__greeting {
+ color: var( --yard-greeting-greeting-color, inherit );
+ font-size: var( --yard-greeting-greeting-font-size, 2rem );
+ font-weight: var( --yard-greeting-greeting-font-weight, 700 );
+ }
+}
diff --git a/src/PhpBlocks/PhpBlockManager.php b/src/PhpBlocks/PhpBlockManager.php
new file mode 100644
index 0000000..fb906dc
--- /dev/null
+++ b/src/PhpBlocks/PhpBlockManager.php
@@ -0,0 +1,168 @@
+renderer = new BladeRenderer();
+ }
+
+ public function boot(): void
+ {
+ \add_action('init', [$this, 'registerBlocks']);
+ }
+
+ public function registerBlocks(): void
+ {
+ foreach ($this->blocks() as $directory => $blockName) {
+ $blockPath = __DIR__ . '/' . $directory;
+
+ \register_block_type($blockPath, [
+ 'render_callback' => $this->renderCallback($directory, $blockName, $blockPath),
+ ]);
+ }
+ }
+
+ /**
+ * The registrable blocks, as folder name => block name.
+ *
+ * The name comes from `block.json`, not from the folder: the folder has to be
+ * a valid namespace segment for the view model to autoload, while the name is
+ * the block's actual identity. Its slug names the template and is the key a
+ * site filters on through `yard::gutenberg/allowed-blocks`, where hyphenated
+ * names like `opening-hours` are perfectly normal.
+ *
+ * @return array
+ */
+ private function blocks(): array
+ {
+ $blocks = [];
+
+ foreach (array_filter(glob(__DIR__ . '/*', GLOB_ONLYDIR) ?: []) as $path) {
+ $blockName = $this->blockName($path . '/block.json');
+
+ if (null !== $blockName) {
+ $blocks[basename($path)] = $blockName;
+ }
+ }
+
+ return $this->filterAllowed($blocks);
+ }
+
+ /**
+ * Apply `yard::gutenberg/allowed-blocks`, which is keyed on the slug.
+ *
+ * `AllowedBlocks::filter()` tests values and preserves keys, so it filters a
+ * folder => slug map, and the surviving keys select from the original.
+ *
+ * @param array $blocks Folder name => block name.
+ *
+ * @return array
+ */
+ private function filterAllowed(array $blocks): array
+ {
+ $allowed = AllowedBlocks::filter(array_map([$this, 'slug'], $blocks));
+
+ return array_intersect_key($blocks, $allowed);
+ }
+
+ /**
+ * The namespaced block name from a `block.json`, e.g. `yard/greeting`.
+ *
+ * Returns null for a folder that holds no readable metadata with a namespaced
+ * block name, so a stray directory is skipped rather than fatal.
+ */
+ private function blockName(string $metadataPath): ?string
+ {
+ if (! file_exists($metadataPath)) {
+ return null;
+ }
+
+ $metadata = json_decode((string) file_get_contents($metadataPath), true);
+ $name = is_array($metadata) && is_string($metadata['name'] ?? null) ? $metadata['name'] : '';
+
+ return '' === $this->slug($name) ? null : $name;
+ }
+
+ /**
+ * The slug of a block name, e.g. `greeting` for `yard/greeting`.
+ */
+ private function slug(string $blockName): string
+ {
+ $separator = strpos($blockName, '/');
+
+ return false === $separator ? '' : substr($blockName, $separator + 1);
+ }
+
+ /**
+ * The class WordPress generates for a block, e.g. `wp-block-yard-greeting`.
+ *
+ * Going through core's function rather than building the string ourselves
+ * means a site filtering `block_default_classname` gets `$blockClass` and
+ * every BEM modifier built on it following along. The function is marked
+ * `@access private`, hence the guard.
+ *
+ * @see wp_get_block_default_classname()
+ */
+ private function blockClass(string $blockName): string
+ {
+ return function_exists('wp_get_block_default_classname')
+ ? (string) \wp_get_block_default_classname($blockName)
+ : 'wp-block-' . str_replace('/', '-', $blockName);
+ }
+
+ /**
+ * Build the render callback for a single block.
+ *
+ * The view model assembles the render data, including the wrapper attributes:
+ * `get_block_wrapper_attributes()` reads the block currently being rendered,
+ * so it has to run inside the callback rather than up front. Blocks without
+ * their own view model get a plain `BlockViewModel`, which adds nothing.
+ */
+ private function renderCallback(string $directory, string $blockName, string $blockPath): callable
+ {
+ $templatePath = $blockPath . '/' . $this->slug($blockName) . '.blade.php';
+ $viewModelClass = __NAMESPACE__ . '\\' . $directory . '\\' . $directory;
+ $blockClass = $this->blockClass($blockName);
+
+ return function ($attributes, $content = '', $block = null) use ($templatePath, $viewModelClass, $blockClass) {
+ $viewModel = is_subclass_of($viewModelClass, BlockViewModel::class)
+ ? new $viewModelClass()
+ : new BlockViewModel();
+
+ return $this->renderer->render($templatePath, $viewModel->compose(
+ is_array($attributes) ? $attributes : [],
+ is_string($content) ? $content : '',
+ $block instanceof \WP_Block ? $block : null,
+ $blockClass
+ ));
+ };
+ }
+}
diff --git a/src/PluginServiceProvider.php b/src/PluginServiceProvider.php
index 0c7fab4..b627630 100644
--- a/src/PluginServiceProvider.php
+++ b/src/PluginServiceProvider.php
@@ -4,6 +4,8 @@
namespace Yard\Gutenberg;
+use Yard\Gutenberg\Support\AllowedBlocks;
+
class PluginServiceProvider
{
public function boot()
@@ -35,6 +37,7 @@ public function bootProviders(): void
Hooks\DefaultHookManager::class,
MyPatterns\MyPatternManager::class,
Patterns\PatternManager::class,
+ PhpBlocks\PhpBlockManager::class,
YardPatterns\YardPatternsManager::class,
];
@@ -89,16 +92,12 @@ public function registerBlocks()
$blockNames = array_map('basename', array_filter(glob($blocksPath . '*', GLOB_ONLYDIR) ?: []));
- if (has_filter('yard::gutenberg/allowed-blocks')) {
- $allowedBlocks = apply_filters('yard::gutenberg/allowed-blocks', []);
+ // Skip leftover directories from an earlier build that no longer hold a block.
+ $blockNames = array_filter($blockNames, function (string $blockName) use ($blocksPath) {
+ return file_exists($blocksPath . $blockName . '/block.json');
+ });
- $blockNames = array_filter(
- $blockNames,
- function (string $blockName) use ($allowedBlocks) {
- return in_array($blockName, $allowedBlocks);
- }
- );
- }
+ $blockNames = AllowedBlocks::filter($blockNames);
foreach ($blockNames as $blockName) {
$blockPath = $blocksPath . $blockName;
diff --git a/src/Support/AllowedBlocks.php b/src/Support/AllowedBlocks.php
new file mode 100644
index 0000000..5ac4ef8
--- /dev/null
+++ b/src/Support/AllowedBlocks.php
@@ -0,0 +1,40 @@
+ {
+ if ( ! Array.isArray( plugin?.patterns ) ) {
+ return;
+ }
+
+ plugin.patterns = plugin.patterns.map( ( pattern ) => ( {
+ ...pattern,
+ globOptions: {
+ ...pattern.globOptions,
+ ignore: [
+ ...( pattern.globOptions?.ignore ?? [] ),
+ '**/PhpBlocks/**',
+ ],
+ },
+ } ) );
+} );
+
+module.exports = config;