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
167 changes: 167 additions & 0 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -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
<p {!! $wrapperAttributes !!}>
<span class="{{ $blockClass }}__text">{{ $greeting }}</span>
</p>
```

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-<slug>` 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.
Expand All @@ -25,6 +172,7 @@ add_filter('yard::gutenberg/allowed-blocks', fn () => [
'collapse-item',
'counting-number',
'facetwp',
'greeting',
'icon',
'iconlist',
'iconlist-item',
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion readme.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
118 changes: 118 additions & 0 deletions src/PhpBlocks/BladeRenderer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
<?php

declare(strict_types=1);

namespace Yard\Gutenberg\PhpBlocks;

/**
* Renders a Blade template through the view engine provided by the host theme.
*
* This plugin ships no view engine of its own: it borrows the one a Sage/Acorn
* theme already boots, the same way `Blocks\facetwp\Facetwp` does. Templates are
* rendered by absolute path via `Illuminate\View\Factory::file()`, so the plugin
* doesn't have to register view paths or a view namespace with the theme.
*/
class BladeRenderer
{
/**
* Templates already reported as unrenderable, so a page full of broken
* blocks logs one notice per template instead of one per block instance.
*
* @var array<string, true>
*/
private static $reported = [];

/**
* Render a Blade template.
*
* @param string $templatePath Absolute path to a `.blade.php` file.
* @param array<string, mixed> $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 '<p>' . \esc_html__('Dit blok kan niet worden weergegeven: er is geen Blade-templateengine beschikbaar.', 'yard-gutenberg') . '</p>';
}

return '';
}
}
Loading