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: ``` +## 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'