├── .gitignore ├── src ├── Exceptions │ ├── DuplicateKeyException.php │ └── InvalidFieldsException.php ├── Console │ ├── stubs │ │ ├── views │ │ │ ├── widget.stub │ │ │ └── block.stub │ │ ├── partial.stub │ │ ├── field.stub │ │ ├── options.stub │ │ ├── widget.stub │ │ ├── options.full.stub │ │ ├── block.stub │ │ └── block.localized.stub │ ├── FieldMakeCommand.php │ ├── PartialMakeCommand.php │ ├── ClearCommand.php │ ├── OptionsMakeCommand.php │ ├── WidgetMakeCommand.php │ ├── StubPublishCommand.php │ ├── IdeHelpersCommand.php │ ├── CacheCommand.php │ ├── UpgradeCommand.php │ ├── BlockMakeCommand.php │ ├── UsageCommand.php │ └── MakeCommand.php ├── Contracts │ ├── Composer.php │ ├── Block.php │ └── Widget.php ├── Concerns │ ├── HasCollection.php │ ├── InteractsWithBlade.php │ └── InteractsWithPartial.php ├── Partial.php ├── Builder │ ├── Concerns │ │ └── HasParentContext.php │ ├── ChoiceFieldBuilder.php │ ├── TabBuilder.php │ ├── AccordionBuilder.php │ ├── FieldBuilder.php │ ├── GroupBuilder.php │ ├── RepeaterBuilder.php │ └── FlexibleContentBuilder.php ├── Field.php ├── Providers │ └── AcfComposerServiceProvider.php ├── Manifest.php ├── Options.php ├── Widget.php ├── Composer.php ├── Builder.php ├── AcfComposer.php └── Block.php ├── resources └── views │ ├── alpine-support.blade.php │ ├── field-usage.blade.php │ └── block-editor-filters.blade.php ├── .editorconfig ├── composer.json ├── LICENSE.md ├── config └── acf.php └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | composer.lock 3 | _ide-helpers.php 4 | -------------------------------------------------------------------------------- /src/Exceptions/DuplicateKeyException.php: -------------------------------------------------------------------------------- 1 | 3 | @foreach ($items as $item) 4 |
No items found!
9 | @endif -------------------------------------------------------------------------------- /resources/views/alpine-support.blade.php: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /src/Contracts/Composer.php: -------------------------------------------------------------------------------- 1 | '', 5 | 'native' => null, 6 | ]) 7 | 8 | @php($field = $native ? "{$native}('{{ $block->preview ? 'Add an item...' : 'No items found!' }}
13 | @endif 14 | 15 |No items found!
283 | @endif 284 | 285 |No items found!
401 | @endif 402 | ``` 403 | 404 | Out of the box, the Example widget is ready to go and should appear in the backend. 405 | 406 | ### Generating an Options Page 407 | 408 | Creating an options page is similar to creating a regular field group in additional to a few configuration options available to customize the page (most of which, are optional.) 409 | 410 | Start by creating an option page using Acorn: 411 | 412 | ```bash 413 | $ wp acorn acf:options ExampleOptions 414 | ``` 415 | 416 | ```php 417 | addRepeater('items') 451 | ->addText('item') 452 | ->endRepeater(); 453 | 454 | return $fields->build(); 455 | } 456 | } 457 | ``` 458 | 459 | Optionally, you may pass `--full` to the command above to generate a stub that contains additional configuration examples. 460 | 461 | ```bash 462 | $ wp acorn acf:options Options --full 463 | ``` 464 | 465 | Once finished, you should see an Options page appear in the backend. 466 | 467 | All fields registered will have their location automatically set to this page. 468 | 469 | ## Caching Blocks & Fields 470 | 471 | As of v3, ACF Composer now has the ability to cache registered blocks to native `block.json` files and field groups to a flat file JSON manifest automatically using CLI. 472 | 473 | This can lead to a **dramatic increase** in performance in projects both small and large, especially when loading a post in the editor containing multiple custom blocks. 474 | 475 | > [!NOTE] 476 | > Making changes to blocks or fields once cached will not take effect until cleared or re-cached. 477 | 478 | The best time to do this is during deployment and can be done using the `acf:cache` command: 479 | 480 | ```bash 481 | $ wp acorn acf:cache [--status] 482 | ``` 483 | 484 | Cache can then be cleared using the `acf:clear` command: 485 | 486 | ```bash 487 | $ wp acorn acf:clear 488 | ``` 489 | 490 | The ACF Composer cache status can be found using `--status` on `acf:cache` as seen above or by running `wp acorn about`. 491 | 492 | ## Custom Stub Generation 493 | 494 | To customize the stubs generated by ACF Composer, you can easily publish the `stubs` directory using Acorn: 495 | 496 | ```bash 497 | $ wp acorn acf:stubs 498 | ``` 499 | 500 | The publish command generates all available stubs by default. However, each stub file is optional. When a stub file doesn't exist in the `stubs/acf-composer` directory, the default stub provided by the package will be used. 501 | 502 | ## Default Field Settings 503 | 504 | A useful feature unique to ACF Composer is the ability to set field type as well as field group defaults. Any globally set default can of course be over-ridden by simply setting it on the individual field. 505 | 506 | ### Global 507 | 508 | Taking a look at `config/acf.php`, you will see a few pre-configured defaults: 509 | 510 | ```php 511 | 'defaults' => [ 512 | 'trueFalse' => ['ui' => 1], 513 | 'select' => ['ui' => 1], 514 | ], 515 | ``` 516 | 517 | When setting `trueFalse` and `select` to have their `ui` set to `1` by default, it is no longer necessary to repeatedly set `'ui' => 1` on your fields. This takes effect globally and can be overridden by simply setting a different value on a field. 518 | 519 | ### Field Group 520 | 521 | It is also possible to define defaults on individual field groups. This is done by simply defining `$defaults` in your field class. 522 | 523 | ```php 524 | /** 525 | * Default field type settings. 526 | * 527 | * @return array 528 | */ 529 | protected $defaults = ['ui' => 0]; 530 | ``` 531 | 532 | ### My Defaults 533 | 534 | Here are a couple defaults I personally use. Any prefixed with `acfe_` are related to [ACF Extended](https://www.acf-extended.com/). 535 | 536 | ```php 537 | 'defaults' => [ 538 | 'fieldGroup' => ['instruction_placement' => 'acfe_instructions_tooltip'], 539 | 'repeater' => ['layout' => 'block', 'acfe_repeater_stylised_button' => 1], 540 | 'trueFalse' => ['ui' => 1], 541 | 'select' => ['ui' => 1], 542 | 'postObject' => ['ui' => 1, 'return_format' => 'object'], 543 | 'accordion' => ['multi_expand' => 1], 544 | 'group' => ['layout' => 'table', 'acfe_group_modal' => 1], 545 | 'tab' => ['placement' => 'left'], 546 | 'sidebar_selector' => ['default_value' => 'sidebar-primary', 'allow_null' => 1] 547 | ], 548 | ``` 549 | 550 | ## Bug Reports 551 | 552 | If you discover a bug in ACF Composer, please [open an issue](https://github.com/log1x/acf-composer/issues). 553 | 554 | ## Contributing 555 | 556 | Contributing whether it be through PRs, reporting an issue, or suggesting an idea is encouraged and appreciated. 557 | 558 | ## License 559 | 560 | ACF Composer is provided under the [MIT License](LICENSE.md). 561 | -------------------------------------------------------------------------------- /src/Block.php: -------------------------------------------------------------------------------- 1 | null, 207 | 'margin' => null, 208 | ]; 209 | 210 | /** 211 | * The supported block features. 212 | * 213 | * @var array 214 | */ 215 | public $supports = []; 216 | 217 | /** 218 | * The block styles. 219 | * 220 | * @var array 221 | */ 222 | public $styles = []; 223 | 224 | /** 225 | * The current block style. 226 | * 227 | * @var string 228 | */ 229 | public $style; 230 | 231 | /** 232 | * The block dimensions. 233 | * 234 | * @var string 235 | */ 236 | public $inlineStyle; 237 | 238 | /** 239 | * Context values inherited by the block. 240 | * 241 | * @var string[] 242 | */ 243 | public $uses_context = []; 244 | 245 | /** 246 | * Context provided by the block. 247 | * 248 | * @var string[] 249 | */ 250 | public $provides_context = []; 251 | 252 | /** 253 | * The block preview example data. 254 | * 255 | * @var array 256 | */ 257 | public $example = []; 258 | 259 | /** 260 | * The block template. 261 | * 262 | * @var array|string 263 | */ 264 | public $template = []; 265 | 266 | /** 267 | * Determine whether to save the block's data as post meta. 268 | * 269 | * @var bool 270 | */ 271 | public $usePostMeta = false; 272 | 273 | /** 274 | * The block API version. 275 | * 276 | * @var int|null 277 | */ 278 | public $apiVersion = null; 279 | 280 | /** 281 | * The internal ACF block version. 282 | * 283 | * @var int 284 | */ 285 | public $blockVersion = 2; 286 | 287 | /** 288 | * Validate block fields as per the field group configuration. 289 | * 290 | * @var bool 291 | */ 292 | public $validate = true; 293 | 294 | /** 295 | * The block attributes. 296 | */ 297 | public function attributes(): array 298 | { 299 | return []; 300 | } 301 | 302 | /** 303 | * Set the attributes to the block properties. 304 | */ 305 | public function mergeAttributes(): void 306 | { 307 | foreach ($this->attributes() as $key => $value) { 308 | if (! property_exists($this, $key)) { 309 | continue; 310 | } 311 | 312 | $this->{$key} = $value; 313 | } 314 | 315 | $defaults = config('acf.blocks', []); 316 | 317 | foreach ($defaults as $key => $value) { 318 | if (! property_exists($this, $key) || filled($this->{$key})) { 319 | continue; 320 | } 321 | 322 | $this->{$key} = $value; 323 | } 324 | } 325 | 326 | /** 327 | * Retrieve the block name. 328 | */ 329 | public function getName(): string 330 | { 331 | return $this->name; 332 | } 333 | 334 | /** 335 | * Retrieve the block description. 336 | */ 337 | public function getDescription(): string 338 | { 339 | return $this->description; 340 | } 341 | 342 | /** 343 | * Retrieve the active block style. 344 | */ 345 | public function getStyle(): ?string 346 | { 347 | return Str::of($this->block->className ?? null) 348 | ->matchAll('/is-style-(\S+)/') 349 | ->get(0) ?? 350 | Arr::get($this->getDefaultStyle(), 'name'); 351 | } 352 | 353 | /** 354 | * Retrieve the default style. 355 | */ 356 | public function getDefaultStyle(): array 357 | { 358 | return $this->collect($this->getStyles())->firstWhere('isDefault') ?? []; 359 | } 360 | 361 | /** 362 | * Retrieve the block styles. 363 | */ 364 | public function getStyles(): array 365 | { 366 | $styles = $this->collect($this->styles)->map(function ($value, $key) { 367 | if (is_array($value)) { 368 | return $value; 369 | } 370 | 371 | $name = is_bool($value) ? $key : $value; 372 | $default = is_bool($value) ? $value : false; 373 | 374 | return [ 375 | 'name' => $name, 376 | 'label' => Str::headline($name), 377 | 'isDefault' => $default, 378 | ]; 379 | }); 380 | 381 | if (! $styles->where('isDefault', true)->count()) { 382 | $styles = $styles->map(fn ($style, $key) => $key === 0 383 | ? Arr::set($style, 'isDefault', true) 384 | : $style 385 | ); 386 | } 387 | 388 | return $styles->all(); 389 | } 390 | 391 | /** 392 | * Retrieve the block supports. 393 | */ 394 | public function getSupports(): array 395 | { 396 | $supports = $this->collect($this->supports) 397 | ->mapWithKeys(fn ($value, $key) => [Str::camel($key) => $value]) 398 | ->merge($this->supports); 399 | 400 | $typography = $supports->get('typography', []); 401 | 402 | if ($supports->has('alignText')) { 403 | $typography['textAlign'] = $supports->get('alignText'); 404 | 405 | $supports->forget(['alignText', 'align_text']); 406 | } 407 | 408 | if ($typography) { 409 | $supports->put('typography', $typography); 410 | } 411 | 412 | return $supports->all(); 413 | } 414 | 415 | /** 416 | * Retrieve the block support attributes. 417 | */ 418 | public function getSupportAttributes(): array 419 | { 420 | $attributes = []; 421 | 422 | if ($this->align) { 423 | $attributes['align'] = [ 424 | 'type' => 'string', 425 | 'default' => $this->align, 426 | ]; 427 | } 428 | 429 | if ($this->align_content) { 430 | $attributes['alignContent'] = [ 431 | 'type' => 'string', 432 | 'default' => $this->align_content, 433 | ]; 434 | } 435 | 436 | $styles = []; 437 | 438 | if ($this->align_text) { 439 | $styles['typography']['textAlign'] = $this->align_text; 440 | } 441 | 442 | $spacing = array_filter($this->spacing); 443 | 444 | if ($spacing) { 445 | $styles['spacing'] = $spacing; 446 | } 447 | 448 | if ($styles) { 449 | $attributes['style'] = [ 450 | 'type' => 'object', 451 | 'default' => $styles, 452 | ]; 453 | } 454 | 455 | return $attributes; 456 | } 457 | 458 | /** 459 | * Retrieve the block HTML attributes. 460 | */ 461 | public function getHtmlAttributes(): array 462 | { 463 | if ($this->preview) { 464 | return []; 465 | } 466 | 467 | return WP_Block_Supports::get_instance()?->apply_block_supports() ?? []; 468 | } 469 | 470 | /** 471 | * Retrieve the inline block styles. 472 | */ 473 | public function getInlineStyle(): string 474 | { 475 | $supports = $this->getHtmlAttributes(); 476 | 477 | return $supports['style'] ?? ''; 478 | } 479 | 480 | /** 481 | * Retrieve the block classes. 482 | */ 483 | public function getClasses(): string 484 | { 485 | $supports = $this->getHtmlAttributes(); 486 | 487 | $class = $supports['class'] ?? ''; 488 | 489 | if ($alignContent = $this->block->alignContent ?? $this->block->align_content ?? null) { 490 | $class = "{$class} is-position-{$alignContent}"; 491 | } 492 | 493 | if ($this->block->fullHeight ?? $this->block->full_height ?? null) { 494 | $class = "{$class} full-height"; 495 | } 496 | 497 | return str_replace( 498 | acf_slugify($this->namespace), 499 | $this->slug, 500 | trim($class) 501 | ); 502 | } 503 | 504 | /** 505 | * Retrieve the component attribute bag. 506 | */ 507 | protected function getComponentAttributeBag(): ComponentAttributeBag 508 | { 509 | return (new ComponentAttributeBag) 510 | ->class($this->getClasses()) 511 | ->style($this->getInlineStyle()) 512 | ->merge(['id' => $this->block->anchor ?? null]) 513 | ->filter(fn ($value) => filled($value) && $value !== ';'); 514 | } 515 | 516 | /** 517 | * Retrieve the block API version. 518 | */ 519 | public function getApiVersion(): int 520 | { 521 | return $this->apiVersion ?? 2; 522 | } 523 | 524 | /** 525 | * Retrieve the block text domain. 526 | */ 527 | public function getTextDomain(): string 528 | { 529 | return $this->textDomain 530 | ?? wp_get_theme()?->get('TextDomain') 531 | ?? 'acf-composer'; 532 | } 533 | 534 | /** 535 | * Retrieve the block icon. 536 | */ 537 | public function getIcon(): string|array 538 | { 539 | if (is_array($this->icon)) { 540 | return $this->icon; 541 | } 542 | 543 | if (Str::startsWith($this->icon, 'asset:')) { 544 | $asset = Str::of($this->icon) 545 | ->after('asset:') 546 | ->before('.svg') 547 | ->replace('.', '/') 548 | ->finish('.svg'); 549 | 550 | return asset($asset)->contents(); 551 | } 552 | 553 | return $this->icon; 554 | } 555 | 556 | /** 557 | * Handle the block template. 558 | */ 559 | public function handleTemplate(array $template = []): Collection 560 | { 561 | return $this->collect($template)->map(function ($block, $key) { 562 | $name = is_numeric($key) 563 | ? array_key_first((array) $block) 564 | : $key; 565 | 566 | $value = is_numeric($key) 567 | ? ($block[$name] ?? []) 568 | : $block; 569 | 570 | if (is_array($value) && isset($value['innerBlocks'])) { 571 | $innerBlocks = $this->handleTemplate($value['innerBlocks'])->all(); 572 | 573 | unset($value['innerBlocks']); 574 | 575 | return [$name, $value, $innerBlocks]; 576 | } 577 | 578 | return [$name, $value]; 579 | })->values(); 580 | } 581 | 582 | /** 583 | * Compose the fields and register the block. 584 | */ 585 | public function compose(): ?self 586 | { 587 | $this->mergeAttributes(); 588 | 589 | if (blank($this->getName())) { 590 | return null; 591 | } 592 | 593 | $this->slug = $this->slug ?: Str::slug(Str::kebab($this->getName())); 594 | $this->view = $this->view ?: Str::start($this->slug, 'blocks.'); 595 | $this->namespace = $this->namespace ?? Str::start($this->slug, $this->prefix); 596 | 597 | if (! Arr::has($this->fields, 'location.0.0')) { 598 | Arr::set($this->fields, 'location.0.0', [ 599 | 'param' => 'block', 600 | 'operator' => '==', 601 | 'value' => $this->namespace, 602 | ]); 603 | } 604 | 605 | $this->register(fn () => $this->hasJson() 606 | ? register_block_type($this->jsonPath()) 607 | : $this->registerBlockType() 608 | ); 609 | 610 | return $this; 611 | } 612 | 613 | /** 614 | * Register the block type. 615 | */ 616 | public function registerBlockType(): void 617 | { 618 | $block = acf_validate_block_type($this->settings()->all()); 619 | $block = apply_filters('acf/register_block_type_args', $block); 620 | 621 | if (acf_has_block_type($block['name'])) { 622 | throw new Exception("Block type [{$block['name']}] is already registered."); 623 | } 624 | 625 | $block['attributes'] = array_merge( 626 | acf_get_block_type_default_attributes($block), 627 | $block['attributes'] ?? [] 628 | ); 629 | 630 | acf_get_store('block-types')->set($block['name'], $block); 631 | 632 | $block['render_callback'] = 'acf_render_block_callback'; 633 | 634 | register_block_type( 635 | $block['name'], 636 | $block 637 | ); 638 | 639 | add_action('enqueue_block_editor_assets', 'acf_enqueue_block_assets'); 640 | } 641 | 642 | /** 643 | * Retrieve the block settings. 644 | */ 645 | public function settings(): Collection 646 | { 647 | if ($this->settings) { 648 | return $this->settings; 649 | } 650 | 651 | $settings = Collection::make([ 652 | 'name' => $this->slug, 653 | 'title' => $this->getName(), 654 | 'description' => $this->getDescription(), 655 | 'category' => $this->category, 656 | 'icon' => $this->getIcon(), 657 | 'keywords' => $this->keywords, 658 | 'post_types' => $this->post_types, 659 | 'mode' => $this->mode, 660 | 'align' => $this->align, 661 | 'attributes' => $this->getSupportAttributes(), 662 | 'alignText' => $this->align_text ?? $this->align, 663 | 'alignContent' => $this->align_content, 664 | 'styles' => $this->getStyles(), 665 | 'supports' => $this->getSupports(), 666 | 'textdomain' => $this->getTextDomain(), 667 | 'acf_block_version' => $this->blockVersion, 668 | 'api_version' => $this->getApiVersion(), 669 | 'validate' => $this->validate, 670 | 'use_post_meta' => $this->usePostMeta, 671 | 'render_callback' => function ( 672 | $block, 673 | $content = '', 674 | $preview = false, 675 | $post_id = 0, 676 | $wp_block = false, 677 | $context = false 678 | ) { 679 | echo $this->render($block, $content, $preview, $post_id, $wp_block, $context); 680 | }, 681 | ]); 682 | 683 | if (filled($this->parent)) { 684 | $settings = $settings->put('parent', $this->parent); 685 | } 686 | 687 | if (filled($this->ancestor)) { 688 | $settings = $settings->put('ancestor', $this->ancestor); 689 | } 690 | 691 | if ($this->example !== false) { 692 | if (method_exists($this, 'example') && is_array($example = $this->example())) { 693 | $this->example = array_merge($this->example, $example); 694 | } 695 | 696 | $settings = $settings->put('example', [ 697 | 'attributes' => [ 698 | 'mode' => 'preview', 699 | 'data' => $this->example, 700 | ], 701 | ]); 702 | } 703 | 704 | if (! empty($this->uses_context)) { 705 | $settings = $settings->put('uses_context', $this->uses_context); 706 | } 707 | 708 | if (! empty($this->provides_context)) { 709 | $settings = $settings->put('provides_context', $this->provides_context); 710 | } 711 | 712 | return $this->settings = $settings; 713 | } 714 | 715 | /** 716 | * Retrieve the Block settings as JSON. 717 | */ 718 | public function toJson(): string 719 | { 720 | $settings = $this->settings() 721 | ->put('name', $this->namespace) 722 | ->put('apiVersion', $this->getApiVersion()) 723 | ->put('acf', [ 724 | 'blockVersion' => $this->blockVersion, 725 | 'mode' => $this->mode, 726 | 'postTypes' => $this->post_types, 727 | 'renderTemplate' => $this::class, 728 | 'usePostMeta' => $this->usePostMeta, 729 | 'validate' => $this->validate, 730 | ]) 731 | ->forget([ 732 | 'api_version', 733 | 'acf_block_version', 734 | 'align', 735 | 'alignContent', 736 | 'alignText', 737 | 'mode', 738 | 'post_types', 739 | 'render_callback', 740 | 'use_post_meta', 741 | 'validate', 742 | ]); 743 | 744 | return $settings->filter()->toJson(JSON_PRETTY_PRINT); 745 | } 746 | 747 | /** 748 | * Retrieve the Block JSON path. 749 | */ 750 | public function jsonPath(): string 751 | { 752 | return $this->composer->manifest()->path("blocks/{$this->slug}/block.json"); 753 | } 754 | 755 | /** 756 | * Determine if the Block has a JSON file. 757 | */ 758 | public function hasJson(): bool 759 | { 760 | return file_exists($this->jsonPath()); 761 | } 762 | 763 | /** 764 | * Render the ACF block. 765 | * 766 | * @param array $block 767 | * @param string $content 768 | * @param bool $preview 769 | * @param int $post_id 770 | * @param \WP_Block $wp_block 771 | * @param array $context 772 | * @return string 773 | */ 774 | public function render($block, $content = '', $preview = false, $post_id = 0, $wp_block = false, $context = false) 775 | { 776 | $this->block = (object) $block; 777 | $this->content = $content; 778 | $this->preview = $preview; 779 | $this->post_id = $post_id; 780 | $this->instance = $wp_block; 781 | $this->context = $context; 782 | 783 | $this->post = get_post($post_id); 784 | 785 | $this->template = is_array($this->template) 786 | ? $this->handleTemplate($this->template)->toJson() 787 | : $this->template; 788 | 789 | $this->classes = $this->getClasses(); 790 | $this->style = $this->getStyle(); 791 | $this->inlineStyle = $this->getInlineStyle(); 792 | 793 | if (! is_admin() && method_exists($this, 'assets')) { 794 | $instance = (array) ($this->block ?? []); 795 | 796 | add_action('enqueue_block_assets', function () use ($instance): void { 797 | $this->assets($instance); 798 | }); 799 | } 800 | 801 | return $this->view($this->view, [ 802 | 'block' => $this, 803 | 'attributes' => $this->getComponentAttributeBag(), 804 | ]); 805 | } 806 | 807 | /** 808 | * Assets enqueued when rendering the block. 809 | * 810 | * @return void 811 | * 812 | * @deprecated Use `assets($block)` instead. 813 | */ 814 | public function enqueue() 815 | { 816 | // 817 | } 818 | } 819 | --------------------------------------------------------------------------------