diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..5f4c48e
--- /dev/null
+++ b/CHANGELOG.md
@@ -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/`.
diff --git a/README.md b/README.md
index ba775ce..2f906d9 100644
--- a/README.md
+++ b/README.md
@@ -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
+==
+{{ title }}
+```
+
+`_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.
diff --git a/classes/BlockManager.php b/classes/BlockManager.php
index e3d2f07..c2218b7 100644
--- a/classes/BlockManager.php
+++ b/classes/BlockManager.php
@@ -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;
@@ -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',
@@ -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
*/
diff --git a/formwidgets/blocks/partials/_block.php b/formwidgets/blocks/partials/_block.php
index c4d2f04..d39edef 100644
--- a/formwidgets/blocks/partials/_block.php
+++ b/formwidgets/blocks/partials/_block.php
@@ -69,6 +69,7 @@ class="form-control blocks-group-search"
@@ -85,4 +86,94 @@ class="form-control blocks-group-search"
+
+
+
diff --git a/tests/classes/BlockManagerTest.php b/tests/classes/BlockManagerTest.php
index 7851af0..af1d597 100644
--- a/tests/classes/BlockManagerTest.php
+++ b/tests/classes/BlockManagerTest.php
@@ -35,6 +35,166 @@ public function setUp(): void
$this->pluginPath = dirname(dirname(__DIR__)) . '/blocks/';
}
+ /**
+ * Invokes the protected resolveIncludes() with the given block config.
+ */
+ protected function resolveIncludes(array $config): array
+ {
+ $method = new \ReflectionMethod(BlockManager::class, 'resolveIncludes');
+ $method->setAccessible(true);
+
+ return $method->invoke($this->manager, $config);
+ }
+
+ protected function includePath(string $file): string
+ {
+ return '$/winter/blocks/tests/fixtures/blocks/includes/' . $file;
+ }
+
+ /**
+ * @testdox merges fields and config from an included file
+ */
+ public function testIncludeMergesDefinitions()
+ {
+ $result = $this->resolveIncludes([
+ 'include' => $this->includePath('_shared.yaml'),
+ 'fields' => [
+ 'title' => ['label' => 'Title', 'type' => 'text'],
+ ],
+ ]);
+
+ // The include key itself is stripped.
+ $this->assertArrayNotHasKey('include', $result);
+
+ // Fields from both the include and the block are present.
+ $this->assertArrayHasKey('shared_field', $result['fields']);
+ $this->assertArrayHasKey('title', $result['fields']);
+
+ // config from the include is merged in too.
+ $this->assertArrayHasKey('shared_config', $result['config']);
+
+ // tabs are not a supported merge key.
+ $this->assertArrayNotHasKey('tabs', $result);
+ }
+
+ /**
+ * @testdox lets the block's own definitions override the include on collision
+ */
+ public function testBlockOverridesInclude()
+ {
+ $result = $this->resolveIncludes([
+ 'include' => $this->includePath('_shared.yaml'),
+ 'fields' => [
+ 'overridden' => ['label' => 'From block', 'type' => 'textarea'],
+ ],
+ ]);
+
+ $this->assertEquals('From block', $result['fields']['overridden']['label']);
+ $this->assertEquals('textarea', $result['fields']['overridden']['type']);
+ }
+
+ /**
+ * @testdox resolves nested includes recursively
+ */
+ public function testNestedIncludesAreResolved()
+ {
+ $result = $this->resolveIncludes([
+ 'include' => $this->includePath('_with_nested.yaml'),
+ 'fields' => [
+ 'own_field' => ['label' => 'Own', 'type' => 'text'],
+ ],
+ ]);
+
+ // base_field (deepest), mid_field (middle), own_field (block) all present.
+ $this->assertArrayHasKey('base_field', $result['fields']);
+ $this->assertArrayHasKey('mid_field', $result['fields']);
+ $this->assertArrayHasKey('own_field', $result['fields']);
+ }
+
+ /**
+ * @testdox does not loop on circular includes and still merges what it can
+ */
+ public function testCircularIncludeIsGuarded()
+ {
+ $result = $this->resolveIncludes([
+ 'include' => $this->includePath('_cycle_a.yaml'),
+ 'fields' => [
+ 'own_field' => ['label' => 'Own', 'type' => 'text'],
+ ],
+ ]);
+
+ // Both ends of the cycle contribute their fields; no infinite recursion.
+ $this->assertArrayHasKey('field_a', $result['fields']);
+ $this->assertArrayHasKey('field_b', $result['fields']);
+ $this->assertArrayHasKey('own_field', $result['fields']);
+ }
+
+ /**
+ * @testdox skips a missing include file without error
+ */
+ public function testMissingIncludeIsSkipped()
+ {
+ $result = $this->resolveIncludes([
+ 'include' => $this->includePath('_does_not_exist.yaml'),
+ 'fields' => [
+ 'title' => ['label' => 'Title', 'type' => 'text'],
+ ],
+ ]);
+
+ $this->assertArrayNotHasKey('include', $result);
+ $this->assertArrayHasKey('title', $result['fields']);
+ $this->assertCount(1, $result['fields']);
+ }
+
+ /**
+ * @testdox accepts a list of includes merged in order
+ */
+ public function testMultipleIncludes()
+ {
+ $result = $this->resolveIncludes([
+ 'include' => [
+ $this->includePath('_base.yaml'),
+ $this->includePath('_shared.yaml'),
+ ],
+ 'fields' => [
+ 'title' => ['label' => 'Title', 'type' => 'text'],
+ ],
+ ]);
+
+ $this->assertArrayHasKey('base_field', $result['fields']);
+ $this->assertArrayHasKey('shared_field', $result['fields']);
+ $this->assertArrayHasKey('title', $result['fields']);
+ }
+
+ /**
+ * @testdox on key collision between two includes, the later include wins
+ */
+ public function testLaterIncludeOverridesEarlierInclude()
+ {
+ $result = $this->resolveIncludes([
+ 'include' => [
+ $this->includePath('_order_a.yaml'),
+ $this->includePath('_order_b.yaml'),
+ ],
+ ]);
+
+ $this->assertSame('From B', $result['fields']['ordered_field']['label']);
+ }
+
+ /**
+ * @testdox leaves a block without an include untouched
+ */
+ public function testNoIncludeIsNoop()
+ {
+ $config = [
+ 'fields' => [
+ 'title' => ['label' => 'Title', 'type' => 'text'],
+ ],
+ ];
+
+ $this->assertEquals($config, $this->resolveIncludes($config));
+ }
+
public function testCanRegisterBlocksDirectly()
{
$this->manager->registerBlock('container', $this->fixturePath . 'container.block');
diff --git a/tests/fixtures/blocks/includes/_base.yaml b/tests/fixtures/blocks/includes/_base.yaml
new file mode 100644
index 0000000..518845f
--- /dev/null
+++ b/tests/fixtures/blocks/includes/_base.yaml
@@ -0,0 +1,4 @@
+fields:
+ base_field:
+ label: Base
+ type: textarea
diff --git a/tests/fixtures/blocks/includes/_cycle_a.yaml b/tests/fixtures/blocks/includes/_cycle_a.yaml
new file mode 100644
index 0000000..95c00fc
--- /dev/null
+++ b/tests/fixtures/blocks/includes/_cycle_a.yaml
@@ -0,0 +1,5 @@
+include: $/winter/blocks/tests/fixtures/blocks/includes/_cycle_b.yaml
+fields:
+ field_a:
+ label: A
+ type: text
diff --git a/tests/fixtures/blocks/includes/_cycle_b.yaml b/tests/fixtures/blocks/includes/_cycle_b.yaml
new file mode 100644
index 0000000..24a203f
--- /dev/null
+++ b/tests/fixtures/blocks/includes/_cycle_b.yaml
@@ -0,0 +1,5 @@
+include: $/winter/blocks/tests/fixtures/blocks/includes/_cycle_a.yaml
+fields:
+ field_b:
+ label: B
+ type: text
diff --git a/tests/fixtures/blocks/includes/_order_a.yaml b/tests/fixtures/blocks/includes/_order_a.yaml
new file mode 100644
index 0000000..660b7ee
--- /dev/null
+++ b/tests/fixtures/blocks/includes/_order_a.yaml
@@ -0,0 +1,4 @@
+fields:
+ ordered_field:
+ label: From A
+ type: text
diff --git a/tests/fixtures/blocks/includes/_order_b.yaml b/tests/fixtures/blocks/includes/_order_b.yaml
new file mode 100644
index 0000000..ba8b8fd
--- /dev/null
+++ b/tests/fixtures/blocks/includes/_order_b.yaml
@@ -0,0 +1,4 @@
+fields:
+ ordered_field:
+ label: From B
+ type: text
diff --git a/tests/fixtures/blocks/includes/_shared.yaml b/tests/fixtures/blocks/includes/_shared.yaml
new file mode 100644
index 0000000..5c7258d
--- /dev/null
+++ b/tests/fixtures/blocks/includes/_shared.yaml
@@ -0,0 +1,11 @@
+fields:
+ shared_field:
+ label: Shared
+ type: text
+ overridden:
+ label: From include
+ type: text
+config:
+ shared_config:
+ label: Shared config
+ type: checkbox
diff --git a/tests/fixtures/blocks/includes/_with_nested.yaml b/tests/fixtures/blocks/includes/_with_nested.yaml
new file mode 100644
index 0000000..4227a29
--- /dev/null
+++ b/tests/fixtures/blocks/includes/_with_nested.yaml
@@ -0,0 +1,5 @@
+include: $/winter/blocks/tests/fixtures/blocks/includes/_base.yaml
+fields:
+ mid_field:
+ label: Mid
+ type: text
diff --git a/updates/version.yaml b/updates/version.yaml
index bd3fca5..959267f 100644
--- a/updates/version.yaml
+++ b/updates/version.yaml
@@ -1,2 +1,5 @@
'1.0.0':
- 'First version of Winter.Blocks'
+'1.1.0':
+ - 'Shared field includes with nested include resolution'
+ - 'Recently used blocks pinned to the Add Block palette'