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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Changelog

## Unreleased

### Shared field includes
- `.block` definitions may declare a top-level `include:` (string or list) to
merge `fields` and `config` from external plain-YAML files.
- Included definitions form the base; the block's own definitions override on
collision. Paths resolve via `File::symbolizePath()` (`$/`, `~/`, `#/`).
- **Nested includes** are resolved recursively, guarded against circular
references.
- A **schema guard** logs a warning when an include would redefine a field with
a different `type`.
- Missing include files are skipped and logged as a warning.

### Editor UX
- **Recently used blocks** are pinned to the top of the "add block" palette
(tracked in `localStorage`, most-recent first).

### Tests
- `BlockManagerTest`: include merging, block-overrides-include precedence,
nested includes, circular-include guard, missing-file skip, multiple includes,
and the no-include no-op.
- Fixtures under `tests/fixtures/blocks/includes/`.
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,73 @@ config:
</{{ config.size }}>
```

## Including shared field definitions

To avoid repeating the same fields (or sections) across many blocks, a block can pull them in from one or more external YAML files via the top-level `include` key.

```yaml
name: Article
description: An article block
icon: icon-newspaper

include: $/myauthor/myplugin/blocks/_seo.yaml

fields:
title:
label: Title
type: text
==
<article>{{ title }}</article>
```

`_seo.yaml` is a plain YAML file (no `==` markup, no block metadata) containing any of `fields` or `config`:

```yaml
# blocks/_seo.yaml
fields:
meta_title:
label: Meta title
type: text
meta_description:
label: Meta description
type: textarea
```

**Multiple includes** — pass a list; they are merged in order:

```yaml
include:
- $/myauthor/myplugin/blocks/_seo.yaml
- ~/app/blocks/_tracking.yaml
```

**Merge rules:**

| | |
|---|---|
| Merged keys | `fields`, `config` |
| Precedence | Included files form the base; the block's own definitions **override** on key collision |
| Order | Multiple includes merge top-to-bottom (later files override earlier ones, the block still wins overall) |
| Nested includes | An included file may itself declare `include:` — resolved recursively, with a circular-reference guard |
| Type guard | Redefining a field with a different `type` than the include logs a warning |
| Missing files | Skipped, and logged as a warning |

**Path resolution** uses the standard Winter path symbols via `File::symbolizePath()`:

| Symbol | Resolves to |
|---|---|
| `$/author/plugin/...` | `plugins/author/plugin/...` |
| `~/...` | application root |
| `#/...` | `storage/app/...` |

---

## Recently used blocks

When adding a block from the palette, the blocks you use most recently are pinned to the top of the list (tracked per browser in `localStorage`, most-recent first). This speeds up repetitive content building where the same few block types are added over and over. No configuration is required.

---

## Using the `blocks` FormWidget

In order to provide an interface for managing block-based content, this plugin provides the `blocks` FormWidget. This widget can be used in the backend as a form field to manage blocks.
Expand Down
139 changes: 138 additions & 1 deletion classes/BlockManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
use Cms\Classes\Theme;
use Event;
use File;
use Log;
use Yaml;
use System\Classes\PluginManager;
use Winter\Storm\Support\Traits\Singleton;
use Winter\Storm\Support\Str;
Expand Down Expand Up @@ -98,7 +100,7 @@ public function getConfigs(string|array|null $tags = null): array
}
}

$configs[pathinfo($block['fileName'])['filename']] = array_except(
$config = array_except(
$block->getAttributes(),
[
'fileName',
Expand All @@ -108,11 +110,146 @@ public function getConfigs(string|array|null $tags = null): array
'code',
]
);

$config = $this->resolveIncludes($config);

$configs[pathinfo($block['fileName'])['filename']] = $config;
}

return $configs;
}

/**
* Resolves an `include` directive in a block definition by merging field
* definitions from one or more external YAML files.
*
* A block may declare:
*
* include: $/author/plugin/blocks/_shared.yaml
* # or
* include:
* - $/author/plugin/blocks/_seo.yaml
* - ~/app/blocks/_tracking.yaml
*
* Each included file is a plain YAML file that may contain any of the keys
* `fields` and `config`. Included definitions are
* merged in order and act as a base; the block's own definitions take
* precedence on key collisions.
*
* Included files may themselves declare an `include` key — nested includes
* are resolved recursively, guarded against circular references.
*
* Paths are resolved with File::symbolizePath(), so the usual Winter symbols
* are supported ($ = plugins, ~ = app, # = app/storage/...).
*
* @param string[] $visited Canonical paths already being resolved (cycle guard).
*/
protected function resolveIncludes(array $config, array $visited = []): array
{
if (empty($config['include'])) {
unset($config['include']);
return $config;
}

$paths = (array) $config['include'];
unset($config['include']);

$mergeKeys = ['fields', 'config'];

// Capture the block's own definitions before the loop so that each
// include sees the original block values, not a previously merged result.
// This ensures the block always wins on collision regardless of include order,
// and that later includes correctly override earlier ones (not the merged state).
$ownByKey = [];
foreach ($mergeKeys as $key) {
$ownByKey[$key] = (isset($config[$key]) && is_array($config[$key])) ? $config[$key] : [];
}

// Accumulates the merged result of the includes only (own definitions
// excluded), so that each new include can freely override the previous
// includes without the block's own values getting mixed in as a
// tie-breaker. The block's own values are applied once, after the loop.
$includedByKey = [];
foreach ($mergeKeys as $key) {
$includedByKey[$key] = [];
}

foreach ($paths as $path) {
if (!is_string($path) || $path === '') {
continue;
}

$realPath = File::symbolizePath($path);
if (!$realPath || !File::exists($realPath)) {
Log::warning("Winter.Blocks: included file not found: {$path}");
continue;
}

$canonical = PathResolver::standardize($realPath);
if (in_array($canonical, $visited, true)) {
Log::warning("Winter.Blocks: circular include detected, skipping: {$path}");
continue;
}

$included = Yaml::parse(File::get($realPath));
if (!is_array($included)) {
continue;
}

// Resolve nested includes first so they form the deepest base layer.
$included = $this->resolveIncludes($included, array_merge($visited, [$canonical]));

foreach ($mergeKeys as $key) {
if (!isset($included[$key]) || !is_array($included[$key])) {
continue;
}

// Warn when a field is redefined with a different type.
$this->warnOnTypeCollisions($key, $included[$key], $ownByKey[$key]);

// Later includes override earlier ones. Kept separate from
// $ownByKey so the block's own values can't act as a
// tie-breaker between includes (that previously made the
// first include always win instead of the last one).
$includedByKey[$key] = array_replace_recursive($includedByKey[$key], $included[$key]);
}
}

foreach ($mergeKeys as $key) {
if (empty($includedByKey[$key])) {
continue;
}

// Merged includes form the base; the block's own definitions always win.
$config[$key] = array_replace_recursive($includedByKey[$key], $ownByKey[$key]);
}

return $config;
}

/**
* Logs a warning when merging an include would redefine a field with a
* different `type`, which is almost always a mistake.
*/
protected function warnOnTypeCollisions(string $key, array $included, array $own): void
{
foreach ($included as $name => $def) {
if (!isset($own[$name]) || !is_array($def) || !is_array($own[$name])) {
continue;
}

$includedType = $def['type'] ?? null;
$ownType = $own[$name]['type'] ?? null;

if ($includedType && $ownType && $includedType !== $ownType) {
Log::warning(
"Winter.Blocks: field '{$name}' redefined with a different type " .
"('{$ownType}' overrides included '{$includedType}') in '{$key}'."
);
}
}
}

/**
* Get the configuration of the provided block type
*/
Expand Down
91 changes: 91 additions & 0 deletions formwidgets/blocks/partials/_block.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ class="form-control blocks-group-search"
<a
href="javascript:;"
data-repeater-add
data-block-code="<?= $item['code'] ?>"
data-request="<?= $this->getEventHandler('onAddItem') ?>"
data-request-data="_repeater_group: '<?= $item['code'] ?>'">
<i class="<?= $item['icon'] ?>"></i>
Expand All @@ -85,4 +86,94 @@ class="form-control blocks-group-search"
</div>
</div>
</script>

<?php
/*
* Inline bootstrap for "recently used blocks": pins the most recently added
* block types to the top of the "add block" palette. Loaded inline (rather
* than only via addJs) so it is guaranteed to reach the page regardless of
* widget asset-path resolution or the asset combiner. A global guard ensures
* the handlers are registered only once even when several block widgets
* render on the same page.
*/
?>
<script>
(function () {
if (window.__blockRecentInit) { return; }
window.__blockRecentInit = true;

var RECENT_KEY = 'wnBlocksRecent';
var RECENT_MAX = 6;

function lsGet(key) {
try { return window.localStorage.getItem(key); } catch (e) { return null; }
}
function lsSet(key, val) {
try { window.localStorage.setItem(key, val); } catch (e) {}
}

function getRecent() {
try {
var raw = lsGet(RECENT_KEY);
var arr = raw ? JSON.parse(raw) : [];
return Array.isArray(arr) ? arr : [];
} catch (e) { return []; }
}

function pushRecent(code) {
if (!code) { return; }
var arr = getRecent().filter(function (c) { return c !== code; });
arr.unshift(code);
arr = arr.slice(0, RECENT_MAX);
lsSet(RECENT_KEY, JSON.stringify(arr));
}

// Record a block as recently used whenever one is added from the palette.
document.addEventListener('click', function (e) {
var a = e.target.closest('[data-block-code]');
if (!a) { return; }
pushRecent(a.getAttribute('data-block-code'));
});

// When a palette grid appears, move recently used blocks to the front.
// Reordering (rather than cloning) avoids duplicates and styling issues
// and degrades gracefully if the markup differs.
function applyRecent() {
document.querySelectorAll('.blocks-group-grid:not([data-recent-processed])')
.forEach(function (grid) {
grid.setAttribute('data-recent-processed', '1');
var recent = getRecent();
// Reverse so the most recent ends up first after successive prepends.
recent.slice().reverse().forEach(function (code) {
var link = grid.querySelector('a[data-block-code="' + code + '"]');
var item = link && link.closest('.blocks-group-item');
if (item && item.parentNode === grid) {
grid.insertBefore(item, grid.firstChild);
}
});
});
}

applyRecent();

var scheduled = false;
var observer = new MutationObserver(function (mutations) {
if (!mutations.some(function (m) { return m.addedNodes.length > 0; })) { return; }
if (scheduled) { return; }
scheduled = true;
setTimeout(function () {
scheduled = false;
applyRecent();
}, 0);
});
function startObserving() {
if (document.body) {
observer.observe(document.body, { childList: true, subtree: true });
} else {
document.addEventListener('DOMContentLoaded', startObserving);
}
}
startObserving();
})();
</script>
</div>
Loading
Loading