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;