From 9c57cd326ae375642e8d112746ee012b4802e1cb Mon Sep 17 00:00:00 2001 From: Bhavik Tank Date: Thu, 21 May 2026 02:23:25 +0530 Subject: [PATCH] feat: add blocks auto enqueue --- inc/Main.php | 2 + inc/Modules/Blocks/Registrar.php | 98 ++++++++++++ inc/helpers/custom-functions.php | 12 +- src/blocks/button/block.json | 34 +++++ src/blocks/button/edit.asset.php | 11 ++ src/blocks/button/edit.js | 79 ++++++++++ src/blocks/button/render.php | 26 ++++ src/blocks/button/view.asset.php | 11 ++ src/blocks/button/view.js | 4 + src/blocks/card/block.json | 38 +++++ src/blocks/card/edit.asset.php | 11 ++ src/blocks/card/edit.js | 102 +++++++++++++ src/blocks/card/render.php | 27 ++++ src/blocks/card/view.asset.php | 11 ++ src/blocks/card/view.js | 4 + src/blocks/hero/block.json | 30 ++++ src/blocks/hero/edit.asset.php | 11 ++ src/blocks/hero/edit.js | 71 +++++++++ src/blocks/hero/render.php | 25 +++ src/blocks/hero/view.asset.php | 11 ++ src/blocks/hero/view.js | 4 + src/blocks/navigation/block.json | 28 ++++ src/blocks/navigation/edit.asset.php | 11 ++ src/blocks/navigation/edit.js | 102 +++++++++++++ src/blocks/navigation/render.php | 38 +++++ src/blocks/navigation/view.asset.php | 11 ++ src/blocks/navigation/view.js | 4 + src/blocks/post-loop/block.json | 38 +++++ src/blocks/post-loop/edit.asset.php | 11 ++ src/blocks/post-loop/edit.js | 85 +++++++++++ src/blocks/post-loop/render.php | 58 +++++++ src/blocks/post-loop/view.asset.php | 11 ++ src/blocks/post-loop/view.js | 4 + src/components/button/button.js | 7 +- src/components/button/button.php | 42 +++-- src/components/button/button.scss | 46 ++++++ src/components/card/card.js | 7 +- src/components/card/card.php | 66 ++++---- src/components/hero/hero.js | 4 + src/components/hero/hero.php | 53 +++++++ src/components/hero/hero.scss | 29 ++++ src/components/navigation/navigation.js | 4 + src/components/navigation/navigation.php | 49 ++++++ src/components/navigation/navigation.scss | 34 +++++ src/components/postloop/postloop.js | 4 + src/components/postloop/postloop.php | 48 ++++++ src/components/postloop/postloop.scss | 35 +++++ .../php/inc/Modules/Blocks/RegistrarTest.php | 87 +++++++++++ tests/php/src/Blocks/BlockRenderTest.php | 143 ++++++++++++++++++ tests/php/src/Components/ComponentTest.php | 132 ++++++++++++++++ webpack.config.js | 4 + 51 files changed, 1761 insertions(+), 56 deletions(-) create mode 100644 inc/Modules/Blocks/Registrar.php create mode 100644 src/blocks/button/block.json create mode 100644 src/blocks/button/edit.asset.php create mode 100644 src/blocks/button/edit.js create mode 100644 src/blocks/button/render.php create mode 100644 src/blocks/button/view.asset.php create mode 100644 src/blocks/button/view.js create mode 100644 src/blocks/card/block.json create mode 100644 src/blocks/card/edit.asset.php create mode 100644 src/blocks/card/edit.js create mode 100644 src/blocks/card/render.php create mode 100644 src/blocks/card/view.asset.php create mode 100644 src/blocks/card/view.js create mode 100644 src/blocks/hero/block.json create mode 100644 src/blocks/hero/edit.asset.php create mode 100644 src/blocks/hero/edit.js create mode 100644 src/blocks/hero/render.php create mode 100644 src/blocks/hero/view.asset.php create mode 100644 src/blocks/hero/view.js create mode 100644 src/blocks/navigation/block.json create mode 100644 src/blocks/navigation/edit.asset.php create mode 100644 src/blocks/navigation/edit.js create mode 100644 src/blocks/navigation/render.php create mode 100644 src/blocks/navigation/view.asset.php create mode 100644 src/blocks/navigation/view.js create mode 100644 src/blocks/post-loop/block.json create mode 100644 src/blocks/post-loop/edit.asset.php create mode 100644 src/blocks/post-loop/edit.js create mode 100644 src/blocks/post-loop/render.php create mode 100644 src/blocks/post-loop/view.asset.php create mode 100644 src/blocks/post-loop/view.js create mode 100644 src/components/hero/hero.js create mode 100644 src/components/hero/hero.php create mode 100644 src/components/hero/hero.scss create mode 100644 src/components/navigation/navigation.js create mode 100644 src/components/navigation/navigation.php create mode 100644 src/components/navigation/navigation.scss create mode 100644 src/components/postloop/postloop.js create mode 100644 src/components/postloop/postloop.php create mode 100644 src/components/postloop/postloop.scss create mode 100644 tests/php/inc/Modules/Blocks/RegistrarTest.php create mode 100644 tests/php/src/Blocks/BlockRenderTest.php create mode 100644 tests/php/src/Components/ComponentTest.php diff --git a/inc/Main.php b/inc/Main.php index c931008f..9fd83027 100644 --- a/inc/Main.php +++ b/inc/Main.php @@ -10,6 +10,7 @@ namespace rtCamp\Theme\Elementary; use rtCamp\Theme\Elementary\Modules\BlockExtensions\MediaTextInteractive; +use rtCamp\Theme\Elementary\Modules\Blocks\Registrar; use rtCamp\Theme\Elementary\Framework\Traits\Singleton; use rtCamp\Theme\Elementary\Core\Assets; @@ -60,5 +61,6 @@ public function elementary_theme_support(): void { */ public function block_extensions(): void { MediaTextInteractive::get_instance(); + Registrar::get_instance(); } } diff --git a/inc/Modules/Blocks/Registrar.php b/inc/Modules/Blocks/Registrar.php new file mode 100644 index 00000000..dc180ff6 --- /dev/null +++ b/inc/Modules/Blocks/Registrar.php @@ -0,0 +1,98 @@ +setup_hooks(); + } + + /** + * Setup hooks. + * + * @return void + */ + protected function setup_hooks(): void { + add_action( 'init', [ $this, 'register_blocks' ] ); + } + + /** + * Register blocks. + * + * @return void + */ + public function register_blocks(): void { + /** + * Filters the directories where the theme looks for blocks to register. + * + * @since 1.0.0 + * + * @param array $paths Array of absolute paths to block directories. + */ + $block_paths = apply_filters( + 'elementary_theme_block_paths', + [ + get_stylesheet_directory() . '/src/blocks', + get_template_directory() . '/src/blocks', + ] + ); + + if ( ! is_array( $block_paths ) || empty( $block_paths ) ) { + return; + } + + $registered_blocks = []; + + foreach ( $block_paths as $blocks_dir ) { + if ( ! is_dir( $blocks_dir ) ) { + continue; + } + + $directories = glob( $blocks_dir . '/*', GLOB_ONLYDIR ); + + if ( empty( $directories ) ) { + continue; + } + + foreach ( $directories as $block_dir ) { + $metadata_file = $block_dir . '/block.json'; + + if ( ! file_exists( $metadata_file ) ) { + continue; + } + + $metadata = wp_json_file_decode( $metadata_file, [ 'associative' => true ] ); + $block_name = is_array( $metadata ) && ! empty( $metadata['name'] ) ? (string) $metadata['name'] : basename( $block_dir ); + + if ( isset( $registered_blocks[ $block_name ] ) || \WP_Block_Type_Registry::get_instance()->is_registered( $block_name ) ) { + continue; + } + + register_block_type( $block_dir ); + $registered_blocks[ $block_name ] = true; + } + } + } +} diff --git a/inc/helpers/custom-functions.php b/inc/helpers/custom-functions.php index 0952dfcc..822d9429 100644 --- a/inc/helpers/custom-functions.php +++ b/inc/helpers/custom-functions.php @@ -18,9 +18,9 @@ * * @since 1.0.0 * - * @param string $name Component name (e.g. 'Button', 'Card'). - * @param array $args Arguments to pass to the component. - * @param array $options Optional. Resolution options. See ComponentLoader::render(). + * @param string $name Component name (e.g. 'Button', 'Card'). + * @param array $args Arguments to pass to the component. + * @param array $options Optional. Resolution options. See ComponentLoader::render(). * * @return void */ @@ -38,9 +38,9 @@ function elementary_theme_component( string $name, array $args = [], array $opti * * @since 1.0.0 * - * @param string $name Component name (e.g. 'Button', 'Card'). - * @param array $args Arguments to pass to the component. - * @param array $options Optional. Resolution options. See ComponentLoader::get(). + * @param string $name Component name (e.g. 'Button', 'Card'). + * @param array $args Arguments to pass to the component. + * @param array $options Optional. Resolution options. See ComponentLoader::get(). * * @return string Rendered component HTML. */ diff --git a/src/blocks/button/block.json b/src/blocks/button/block.json new file mode 100644 index 00000000..683f5165 --- /dev/null +++ b/src/blocks/button/block.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "rtcamp/button", + "title": "Button (rtCamp)", + "category": "design", + "icon": "button", + "description": "A button block.", + "attributes": { + "label": { + "type": "string", + "default": "Get started" + }, + "url": { + "type": "string", + "default": "" + }, + "variant": { + "type": "string", + "default": "primary" + }, + "size": { + "type": "string", + "default": "medium" + }, + "class": { + "type": "string", + "default": "" + } + }, + "editorScript": "file:./edit.js", + "viewScript": "file:./view.js", + "render": "file:./render.php" +} diff --git a/src/blocks/button/edit.asset.php b/src/blocks/button/edit.asset.php new file mode 100644 index 00000000..91d48621 --- /dev/null +++ b/src/blocks/button/edit.asset.php @@ -0,0 +1,11 @@ + [ 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-element' ], + 'version' => filemtime( __DIR__ . '/edit.js' ), +]; diff --git a/src/blocks/button/edit.js b/src/blocks/button/edit.js new file mode 100644 index 00000000..42989155 --- /dev/null +++ b/src/blocks/button/edit.js @@ -0,0 +1,79 @@ +/** + * Editor behavior for the rtcamp/button block. + */ +( function() { + const { registerBlockType } = wp.blocks; + const { InspectorControls, RichText, URLInputButton } = wp.blockEditor; + const { PanelBody, SelectControl, TextControl } = wp.components; + const { createElement: el } = wp.element; + + registerBlockType( 'rtcamp/button', { + edit( { attributes, setAttributes } ) { + const label = attributes.label || ''; + const url = attributes.url || ''; + const variant = attributes.variant || 'primary'; + const size = attributes.size || 'medium'; + const className = [ + 'elementary-button', + `elementary-button--${ variant }`, + `elementary-button--${ size }`, + attributes.class || '', + ] + .filter( Boolean ) + .join( ' ' ); + + return el( + 'div', + {}, + el( + InspectorControls, + {}, + el( + PanelBody, + { title: 'Button settings' }, + el( TextControl, { + label: 'URL', + value: url, + onChange: ( value ) => setAttributes( { url: value } ), + } ), + el( URLInputButton, { + url, + onChange: ( value ) => setAttributes( { url: value } ), + } ), + el( SelectControl, { + label: 'Variant', + value: variant, + options: [ + { label: 'Primary', value: 'primary' }, + { label: 'Secondary', value: 'secondary' }, + { label: 'Text', value: 'text' }, + ], + onChange: ( value ) => setAttributes( { variant: value } ), + } ), + el( SelectControl, { + label: 'Size', + value: size, + options: [ + { label: 'Small', value: 'small' }, + { label: 'Medium', value: 'medium' }, + { label: 'Large', value: 'large' }, + ], + onChange: ( value ) => setAttributes( { size: value } ), + } ), + ), + ), + el( RichText, { + tagName: 'span', + className, + value: label, + allowedFormats: [], + placeholder: 'Button label', + onChange: ( value ) => setAttributes( { label: value } ), + } ), + ); + }, + save() { + return null; + }, + } ); +}() ); diff --git a/src/blocks/button/render.php b/src/blocks/button/render.php new file mode 100644 index 00000000..040103e1 --- /dev/null +++ b/src/blocks/button/render.php @@ -0,0 +1,26 @@ + isset( $attributes['label'] ) ? sanitize_text_field( (string) $attributes['label'] ) : '', + 'url' => isset( $attributes['url'] ) ? esc_url_raw( (string) $attributes['url'] ) : '', + 'variant' => isset( $attributes['variant'] ) ? sanitize_key( (string) $attributes['variant'] ) : 'primary', + 'size' => isset( $attributes['size'] ) ? sanitize_key( (string) $attributes['size'] ) : 'medium', + 'class' => isset( $attributes['class'] ) ? sanitize_text_field( (string) $attributes['class'] ) : '', +]; + +// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped +echo elementary_theme_get_component( 'Button', $props ); diff --git a/src/blocks/button/view.asset.php b/src/blocks/button/view.asset.php new file mode 100644 index 00000000..8b300e1f --- /dev/null +++ b/src/blocks/button/view.asset.php @@ -0,0 +1,11 @@ + [], + 'version' => filemtime( __DIR__ . '/view.js' ), +]; diff --git a/src/blocks/button/view.js b/src/blocks/button/view.js new file mode 100644 index 00000000..cb588e7c --- /dev/null +++ b/src/blocks/button/view.js @@ -0,0 +1,4 @@ +/** + * Frontend behavior for the rtcamp/button block. + */ +document.documentElement.classList.add( 'has-rtcamp-button-block' ); diff --git a/src/blocks/card/block.json b/src/blocks/card/block.json new file mode 100644 index 00000000..d886b6c4 --- /dev/null +++ b/src/blocks/card/block.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "rtcamp/card", + "title": "Card (rtCamp)", + "category": "design", + "icon": "index-card", + "description": "A card block backed by the shared Card component.", + "attributes": { + "title": { + "type": "string", + "default": "Card title" + }, + "description": { + "type": "string", + "default": "" + }, + "imageUrl": { + "type": "string", + "default": "" + }, + "imageAlt": { + "type": "string", + "default": "" + }, + "url": { + "type": "string", + "default": "" + }, + "buttonLabel": { + "type": "string", + "default": "Read more" + } + }, + "editorScript": "file:./edit.js", + "viewScript": "file:./view.js", + "render": "file:./render.php" +} diff --git a/src/blocks/card/edit.asset.php b/src/blocks/card/edit.asset.php new file mode 100644 index 00000000..c7f77920 --- /dev/null +++ b/src/blocks/card/edit.asset.php @@ -0,0 +1,11 @@ + [ 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-element' ], + 'version' => filemtime( __DIR__ . '/edit.js' ), +]; diff --git a/src/blocks/card/edit.js b/src/blocks/card/edit.js new file mode 100644 index 00000000..a05cb8f6 --- /dev/null +++ b/src/blocks/card/edit.js @@ -0,0 +1,102 @@ +/** + * Editor behavior for the rtcamp/card block. + */ +( function() { + const { registerBlockType } = wp.blocks; + const { InspectorControls, RichText, URLInputButton, MediaUpload, MediaUploadCheck } = wp.blockEditor; + const { Button, PanelBody, TextControl } = wp.components; + const { createElement: el } = wp.element; + + registerBlockType( 'rtcamp/card', { + edit( { attributes, setAttributes } ) { + const title = attributes.title || ''; + const description = attributes.description || ''; + const imageUrl = attributes.imageUrl || ''; + const imageAlt = attributes.imageAlt || ''; + const url = attributes.url || ''; + const buttonLabel = attributes.buttonLabel || ''; + + return el( + 'article', + { className: 'elementary-card' }, + el( + InspectorControls, + {}, + el( + PanelBody, + { title: 'Card settings' }, + el( TextControl, { + label: 'Card URL', + value: url, + onChange: ( value ) => setAttributes( { url: value } ), + } ), + el( URLInputButton, { + url, + onChange: ( value ) => setAttributes( { url: value } ), + } ), + el( TextControl, { + label: 'Image alt text', + value: imageAlt, + onChange: ( value ) => setAttributes( { imageAlt: value } ), + } ), + ), + ), + el( + MediaUploadCheck, + {}, + el( MediaUpload, { + allowedTypes: [ 'image' ], + value: imageUrl, + onSelect: ( media ) => + setAttributes( { + imageUrl: media.url, + imageAlt: media.alt || imageAlt, + } ), + render: ( { open } ) => + imageUrl + ? el( 'div', { className: 'elementary-card__image' }, [ + el( 'img', { key: 'image', src: imageUrl, alt: imageAlt } ), + el( + Button, + { key: 'button', variant: 'secondary', onClick: open }, + 'Replace image', + ), + ] ) + : el( Button, { variant: 'secondary', onClick: open }, 'Select image' ), + } ), + ), + el( + 'div', + { className: 'elementary-card__content' }, + el( RichText, { + tagName: 'h3', + className: 'elementary-card__title', + value: title, + allowedFormats: [], + placeholder: 'Card title', + onChange: ( value ) => setAttributes( { title: value } ), + } ), + el( RichText, { + tagName: 'p', + className: 'elementary-card__description', + value: description, + allowedFormats: [], + placeholder: 'Card description', + onChange: ( value ) => setAttributes( { description: value } ), + } ), + el( RichText, { + tagName: 'span', + className: 'elementary-button elementary-button--secondary elementary-button--medium', + value: buttonLabel, + allowedFormats: [], + placeholder: 'Button label', + onChange: ( value ) => setAttributes( { buttonLabel: value } ), + } ), + ), + ); + }, + save() { + return null; + }, + } ); +}() ); diff --git a/src/blocks/card/render.php b/src/blocks/card/render.php new file mode 100644 index 00000000..c7ac5580 --- /dev/null +++ b/src/blocks/card/render.php @@ -0,0 +1,27 @@ + isset( $attributes['title'] ) ? sanitize_text_field( (string) $attributes['title'] ) : '', + 'description' => isset( $attributes['description'] ) ? sanitize_textarea_field( (string) $attributes['description'] ) : '', + 'image_url' => isset( $attributes['imageUrl'] ) ? esc_url_raw( (string) $attributes['imageUrl'] ) : '', + 'image_alt' => isset( $attributes['imageAlt'] ) ? sanitize_text_field( (string) $attributes['imageAlt'] ) : '', + 'url' => isset( $attributes['url'] ) ? esc_url_raw( (string) $attributes['url'] ) : '', + 'button_label' => isset( $attributes['buttonLabel'] ) ? sanitize_text_field( (string) $attributes['buttonLabel'] ) : '', +]; + +// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped +echo elementary_theme_get_component( 'Card', $props ); diff --git a/src/blocks/card/view.asset.php b/src/blocks/card/view.asset.php new file mode 100644 index 00000000..a4408430 --- /dev/null +++ b/src/blocks/card/view.asset.php @@ -0,0 +1,11 @@ + [], + 'version' => filemtime( __DIR__ . '/view.js' ), +]; diff --git a/src/blocks/card/view.js b/src/blocks/card/view.js new file mode 100644 index 00000000..3aa47b09 --- /dev/null +++ b/src/blocks/card/view.js @@ -0,0 +1,4 @@ +/** + * Frontend behavior for the rtcamp/card block. + */ +document.documentElement.classList.add( 'has-rtcamp-card-block' ); diff --git a/src/blocks/hero/block.json b/src/blocks/hero/block.json new file mode 100644 index 00000000..0ff1cdac --- /dev/null +++ b/src/blocks/hero/block.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "rtcamp/hero", + "title": "Hero (rtCamp)", + "category": "design", + "icon": "cover-image", + "description": "A hero block.", + "attributes": { + "title": { + "type": "string", + "default": "Hero Title" + }, + "subtitle": { + "type": "string", + "default": "" + }, + "buttonLabel": { + "type": "string", + "default": "" + }, + "buttonUrl": { + "type": "string", + "default": "" + } + }, + "editorScript": "file:./edit.js", + "viewScript": "file:./view.js", + "render": "file:./render.php" +} diff --git a/src/blocks/hero/edit.asset.php b/src/blocks/hero/edit.asset.php new file mode 100644 index 00000000..2839f6b3 --- /dev/null +++ b/src/blocks/hero/edit.asset.php @@ -0,0 +1,11 @@ + [ 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-element' ], + 'version' => filemtime( __DIR__ . '/edit.js' ), +]; diff --git a/src/blocks/hero/edit.js b/src/blocks/hero/edit.js new file mode 100644 index 00000000..628e3b0c --- /dev/null +++ b/src/blocks/hero/edit.js @@ -0,0 +1,71 @@ +/** + * Editor behavior for the rtcamp/hero block. + */ +( function() { + const { registerBlockType } = wp.blocks; + const { InspectorControls, RichText, URLInputButton } = wp.blockEditor; + const { PanelBody, TextControl } = wp.components; + const { createElement: el } = wp.element; + + registerBlockType( 'rtcamp/hero', { + edit( { attributes, setAttributes } ) { + const title = attributes.title || ''; + const subtitle = attributes.subtitle || ''; + const buttonLabel = attributes.buttonLabel || ''; + const buttonUrl = attributes.buttonUrl || ''; + + return el( + 'section', + { className: 'elementary-hero' }, + el( + InspectorControls, + {}, + el( + PanelBody, + { title: 'Hero settings' }, + el( TextControl, { + label: 'Button URL', + value: buttonUrl, + onChange: ( value ) => setAttributes( { buttonUrl: value } ), + } ), + el( URLInputButton, { + url: buttonUrl, + onChange: ( value ) => setAttributes( { buttonUrl: value } ), + } ), + ), + ), + el( + 'div', + { className: 'elementary-hero__content' }, + el( RichText, { + tagName: 'h1', + className: 'elementary-hero__title', + value: title, + allowedFormats: [], + placeholder: 'Hero title', + onChange: ( value ) => setAttributes( { title: value } ), + } ), + el( RichText, { + tagName: 'p', + className: 'elementary-hero__subtitle', + value: subtitle, + allowedFormats: [], + placeholder: 'Supporting text', + onChange: ( value ) => setAttributes( { subtitle: value } ), + } ), + el( RichText, { + tagName: 'span', + className: 'elementary-button elementary-button--primary elementary-button--medium', + value: buttonLabel, + allowedFormats: [], + placeholder: 'CTA label', + onChange: ( value ) => setAttributes( { buttonLabel: value } ), + } ), + ), + ); + }, + save() { + return null; + }, + } ); +}() ); diff --git a/src/blocks/hero/render.php b/src/blocks/hero/render.php new file mode 100644 index 00000000..d64911f9 --- /dev/null +++ b/src/blocks/hero/render.php @@ -0,0 +1,25 @@ + isset( $attributes['title'] ) ? sanitize_text_field( (string) $attributes['title'] ) : '', + 'subtitle' => isset( $attributes['subtitle'] ) ? sanitize_text_field( (string) $attributes['subtitle'] ) : '', + 'buttonLabel' => isset( $attributes['buttonLabel'] ) ? sanitize_text_field( (string) $attributes['buttonLabel'] ) : '', + 'buttonUrl' => isset( $attributes['buttonUrl'] ) ? esc_url_raw( (string) $attributes['buttonUrl'] ) : '', +]; + +// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped +echo elementary_theme_get_component( 'Hero', $props ); diff --git a/src/blocks/hero/view.asset.php b/src/blocks/hero/view.asset.php new file mode 100644 index 00000000..e73f64c4 --- /dev/null +++ b/src/blocks/hero/view.asset.php @@ -0,0 +1,11 @@ + [], + 'version' => filemtime( __DIR__ . '/view.js' ), +]; diff --git a/src/blocks/hero/view.js b/src/blocks/hero/view.js new file mode 100644 index 00000000..fb6e61d3 --- /dev/null +++ b/src/blocks/hero/view.js @@ -0,0 +1,4 @@ +/** + * Frontend behavior for the rtcamp/hero block. + */ +document.documentElement.classList.add( 'has-rtcamp-hero-block' ); diff --git a/src/blocks/navigation/block.json b/src/blocks/navigation/block.json new file mode 100644 index 00000000..a6e23f7e --- /dev/null +++ b/src/blocks/navigation/block.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "rtcamp/navigation", + "title": "Navigation (rtCamp)", + "category": "theme", + "icon": "menu", + "description": "A simple navigation block backed by the shared Navigation component.", + "attributes": { + "label": { + "type": "string", + "default": "Primary navigation" + }, + "items": { + "type": "array", + "default": [ + { + "label": "Home", + "url": "/", + "current": false + } + ] + } + }, + "editorScript": "file:./edit.js", + "viewScript": "file:./view.js", + "render": "file:./render.php" +} diff --git a/src/blocks/navigation/edit.asset.php b/src/blocks/navigation/edit.asset.php new file mode 100644 index 00000000..23385ac4 --- /dev/null +++ b/src/blocks/navigation/edit.asset.php @@ -0,0 +1,11 @@ + [ 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-element' ], + 'version' => filemtime( __DIR__ . '/edit.js' ), +]; diff --git a/src/blocks/navigation/edit.js b/src/blocks/navigation/edit.js new file mode 100644 index 00000000..9771c5a9 --- /dev/null +++ b/src/blocks/navigation/edit.js @@ -0,0 +1,102 @@ +/** + * Editor behavior for the rtcamp/navigation block. + */ +( function() { + const { registerBlockType } = wp.blocks; + const { InspectorControls } = wp.blockEditor; + const { Button, CheckboxControl, PanelBody, TextControl } = wp.components; + const { createElement: el } = wp.element; + + const updateItem = ( items, index, nextItem ) => + items.map( ( item, itemIndex ) => ( itemIndex === index ? { ...item, ...nextItem } : item ) ); + + registerBlockType( 'rtcamp/navigation', { + edit( { attributes, setAttributes } ) { + const label = attributes.label || 'Primary navigation'; + const items = Array.isArray( attributes.items ) ? attributes.items : []; + + return el( + 'nav', + { className: 'elementary-navigation', 'aria-label': label }, + el( + InspectorControls, + {}, + el( + PanelBody, + { title: 'Navigation settings' }, + el( TextControl, { + label: 'Landmark label', + value: label, + onChange: ( value ) => setAttributes( { label: value } ), + } ), + items.map( ( item, index ) => + el( + 'div', + { key: index, className: 'elementary-navigation-editor__item' }, + el( TextControl, { + label: `Item ${ index + 1 } label`, + value: item.label || '', + onChange: ( value ) => + setAttributes( { items: updateItem( items, index, { label: value } ) } ), + } ), + el( TextControl, { + label: `Item ${ index + 1 } URL`, + value: item.url || '', + onChange: ( value ) => + setAttributes( { items: updateItem( items, index, { url: value } ) } ), + } ), + el( CheckboxControl, { + label: 'Current page', + checked: !! item.current, + onChange: ( value ) => + setAttributes( { items: updateItem( items, index, { current: value } ) } ), + } ), + el( + Button, + { + variant: 'link', + isDestructive: true, + onClick: () => + setAttributes( { + items: items.filter( ( _item, itemIndex ) => itemIndex !== index ), + } ), + }, + 'Remove item', + ), + ), + ), + el( + Button, + { + variant: 'secondary', + onClick: () => + setAttributes( { + items: [ ...items, { label: 'New item', url: '/', current: false } ], + } ), + }, + 'Add item', + ), + ), + ), + el( + 'ul', + { className: 'elementary-navigation__list' }, + items.map( ( item, index ) => + el( + 'li', + { key: index, className: 'elementary-navigation__item' }, + el( + 'span', + { className: 'elementary-navigation__link' }, + item.label || 'Navigation item', + ), + ), + ), + ), + ); + }, + save() { + return null; + }, + } ); +}() ); diff --git a/src/blocks/navigation/render.php b/src/blocks/navigation/render.php new file mode 100644 index 00000000..3725a00d --- /dev/null +++ b/src/blocks/navigation/render.php @@ -0,0 +1,38 @@ + isset( $item['label'] ) ? sanitize_text_field( (string) $item['label'] ) : '', + 'url' => isset( $item['url'] ) ? esc_url_raw( (string) $item['url'] ) : '', + 'current' => ! empty( $item['current'] ), + ]; +} + +$props = [ + 'label' => isset( $attributes['label'] ) ? sanitize_text_field( (string) $attributes['label'] ) : '', + 'items' => $items, +]; + +// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped +echo elementary_theme_get_component( 'Navigation', $props ); diff --git a/src/blocks/navigation/view.asset.php b/src/blocks/navigation/view.asset.php new file mode 100644 index 00000000..322b61a4 --- /dev/null +++ b/src/blocks/navigation/view.asset.php @@ -0,0 +1,11 @@ + [], + 'version' => filemtime( __DIR__ . '/view.js' ), +]; diff --git a/src/blocks/navigation/view.js b/src/blocks/navigation/view.js new file mode 100644 index 00000000..dc406ffc --- /dev/null +++ b/src/blocks/navigation/view.js @@ -0,0 +1,4 @@ +/** + * Frontend behavior for the rtcamp/navigation block. + */ +document.documentElement.classList.add( 'has-rtcamp-navigation-block' ); diff --git a/src/blocks/post-loop/block.json b/src/blocks/post-loop/block.json new file mode 100644 index 00000000..6be57762 --- /dev/null +++ b/src/blocks/post-loop/block.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "rtcamp/post-loop", + "title": "Post Loop (rtCamp)", + "category": "theme", + "icon": "list-view", + "description": "A query-backed post loop block that renders through the shared PostLoop component.", + "attributes": { + "postType": { + "type": "string", + "default": "post" + }, + "postsPerPage": { + "type": "number", + "default": 3 + }, + "orderBy": { + "type": "string", + "default": "date" + }, + "order": { + "type": "string", + "default": "desc" + }, + "displayExcerpt": { + "type": "boolean", + "default": true + }, + "emptyMessage": { + "type": "string", + "default": "No posts found." + } + }, + "editorScript": "file:./edit.js", + "viewScript": "file:./view.js", + "render": "file:./render.php" +} diff --git a/src/blocks/post-loop/edit.asset.php b/src/blocks/post-loop/edit.asset.php new file mode 100644 index 00000000..5ed95048 --- /dev/null +++ b/src/blocks/post-loop/edit.asset.php @@ -0,0 +1,11 @@ + [ 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-element', 'wp-server-side-render' ], + 'version' => filemtime( __DIR__ . '/edit.js' ), +]; diff --git a/src/blocks/post-loop/edit.js b/src/blocks/post-loop/edit.js new file mode 100644 index 00000000..60d53aa5 --- /dev/null +++ b/src/blocks/post-loop/edit.js @@ -0,0 +1,85 @@ +/** + * Editor behavior for the rtcamp/post-loop block. + */ +( function() { + const { registerBlockType } = wp.blocks; + const { InspectorControls } = wp.blockEditor; + const { PanelBody, RangeControl, SelectControl, TextControl, ToggleControl } = wp.components; + const { createElement: el } = wp.element; + const ServerSideRender = wp.serverSideRender; + + registerBlockType( 'rtcamp/post-loop', { + edit( { attributes, setAttributes } ) { + const postType = attributes.postType || 'post'; + const postsPerPage = attributes.postsPerPage || 3; + const orderBy = attributes.orderBy || 'date'; + const order = attributes.order || 'desc'; + const displayExcerpt = attributes.displayExcerpt !== false; + const emptyMessage = attributes.emptyMessage || 'No posts found.'; + + return el( + 'div', + {}, + el( + InspectorControls, + {}, + el( + PanelBody, + { title: 'Query settings' }, + el( TextControl, { + label: 'Post type', + value: postType, + onChange: ( value ) => setAttributes( { postType: value } ), + } ), + el( RangeControl, { + label: 'Posts per page', + value: postsPerPage, + min: 1, + max: 12, + onChange: ( value ) => setAttributes( { postsPerPage: value } ), + } ), + el( SelectControl, { + label: 'Order by', + value: orderBy, + options: [ + { label: 'Date', value: 'date' }, + { label: 'Title', value: 'title' }, + { label: 'Menu order', value: 'menu_order' }, + ], + onChange: ( value ) => setAttributes( { orderBy: value } ), + } ), + el( SelectControl, { + label: 'Order', + value: order, + options: [ + { label: 'Descending', value: 'desc' }, + { label: 'Ascending', value: 'asc' }, + ], + onChange: ( value ) => setAttributes( { order: value } ), + } ), + el( ToggleControl, { + label: 'Show excerpts', + checked: displayExcerpt, + onChange: ( value ) => setAttributes( { displayExcerpt: value } ), + } ), + el( TextControl, { + label: 'Empty message', + value: emptyMessage, + onChange: ( value ) => setAttributes( { emptyMessage: value } ), + } ), + ), + ), + ServerSideRender + ? el( ServerSideRender, { block: 'rtcamp/post-loop', attributes } ) + : el( + 'div', + { className: 'elementary-post-loop' }, + el( 'p', { className: 'elementary-post-loop__empty-message' }, emptyMessage ), + ), + ); + }, + save() { + return null; + }, + } ); +}() ); diff --git a/src/blocks/post-loop/render.php b/src/blocks/post-loop/render.php new file mode 100644 index 00000000..ce13094f --- /dev/null +++ b/src/blocks/post-loop/render.php @@ -0,0 +1,58 @@ + 'post', + 'post_status' => 'publish', + 'posts_per_page' => $posts_per_page, + 'orderby' => $selected_order_by, + 'order' => $selected_order, + 'no_found_rows' => true, +]; + +$query_args['post_type'] = post_type_exists( $selected_post_type ) ? $selected_post_type : 'post'; +$query = new WP_Query( $query_args ); +$items = []; + +foreach ( $query->posts as $queried_post ) { + $items[] = [ + 'title' => get_the_title( $queried_post ), + 'url' => get_permalink( $queried_post ), + 'excerpt' => $display_excerpt ? wp_trim_words( get_the_excerpt( $queried_post ), 24 ) : '', + ]; +} + +// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped +echo elementary_theme_get_component( + 'PostLoop', + [ + 'items' => $items, + 'emptyMessage' => $empty_message, + ] +); diff --git a/src/blocks/post-loop/view.asset.php b/src/blocks/post-loop/view.asset.php new file mode 100644 index 00000000..340ddd48 --- /dev/null +++ b/src/blocks/post-loop/view.asset.php @@ -0,0 +1,11 @@ + [], + 'version' => filemtime( __DIR__ . '/view.js' ), +]; diff --git a/src/blocks/post-loop/view.js b/src/blocks/post-loop/view.js new file mode 100644 index 00000000..d8951516 --- /dev/null +++ b/src/blocks/post-loop/view.js @@ -0,0 +1,4 @@ +/** + * Frontend behavior for the rtcamp/post-loop block. + */ +document.documentElement.classList.add( 'has-rtcamp-post-loop-block' ); diff --git a/src/components/button/button.js b/src/components/button/button.js index 1c27c403..b3b14566 100644 --- a/src/components/button/button.js +++ b/src/components/button/button.js @@ -1,9 +1,4 @@ /** * Button component script. */ -document.addEventListener('DOMContentLoaded', () => { - const buttons = document.querySelectorAll('.elementary-button'); - if (buttons.length > 0) { - console.log(`Elementary Button component loaded. Found ${buttons.length} buttons.`); - } -}); +document.documentElement.classList.add( 'has-elementary-button-component' ); diff --git a/src/components/button/button.php b/src/components/button/button.php index 4b0f682e..8607b2e4 100644 --- a/src/components/button/button.php +++ b/src/components/button/button.php @@ -9,35 +9,55 @@ * @param array $args { * Component arguments. * - * @type string $label Button label text. Required. - * @type string $url URL for link buttons. Optional. - * @type string $class Additional CSS classes. Optional. - * @type string $tag HTML tag: 'a' or 'button'. Optional. Defaults to 'a' when $url is set, 'button' otherwise. + * @type string $label Button label text. Required. + * @type string $url URL for link buttons. Optional. + * @type string $class Additional CSS classes. Optional. + * @type string $variant Visual variant. Optional. Defaults to 'primary'. + * @type string $size Visual size. Optional. Defaults to 'medium'. + * @type string $tag HTML tag: 'a' or 'button'. Optional. Defaults to 'a' when $url is set, 'button' otherwise. + * @type string $aria_label Accessible label when visible label needs more context. Optional. + * @type string $target Link target. Optional. * } */ -$label = $args['label'] ?? ''; -$url = $args['url'] ?? ''; -$class = $args['class'] ?? ''; -$tag = $args['tag'] ?? ( ! empty( $url ) ? 'a' : 'button' ); +$label = isset( $args['label'] ) ? (string) $args['label'] : ''; +$url = isset( $args['url'] ) ? (string) $args['url'] : ''; +$class = isset( $args['class'] ) ? (string) $args['class'] : ''; +$variant = isset( $args['variant'] ) ? sanitize_key( (string) $args['variant'] ) : 'primary'; +$size = isset( $args['size'] ) ? sanitize_key( (string) $args['size'] ) : 'medium'; +$tag = isset( $args['tag'] ) ? (string) $args['tag'] : ( ! empty( $url ) ? 'a' : 'button' ); +$aria_label = isset( $args['aria_label'] ) ? (string) $args['aria_label'] : ''; +$target = isset( $args['target'] ) ? (string) $args['target'] : ''; if ( empty( $label ) ) { return; } -$css_class = trim( 'elementary-button ' . $class ); +$allowed_variants = [ 'primary', 'secondary', 'text' ]; +$allowed_sizes = [ 'small', 'medium', 'large' ]; +$variant = in_array( $variant, $allowed_variants, true ) ? $variant : 'primary'; +$size = in_array( $size, $allowed_sizes, true ) ? $size : 'medium'; +$css_class = trim( sprintf( 'elementary-button elementary-button--%1$s elementary-button--%2$s %3$s', $variant, $size, $class ) ); +$aria_attribute = ! empty( $aria_label ) ? sprintf( ' aria-label="%s"', esc_attr( $aria_label ) ) : ''; if ( 'a' === $tag && ! empty( $url ) ) { + $target_attribute = in_array( $target, [ '_blank', '_self', '_parent', '_top' ], true ) ? sprintf( ' target="%s"', esc_attr( $target ) ) : ''; + $rel_attribute = '_blank' === $target ? ' rel="noopener noreferrer"' : ''; + printf( - '%s', + '%6$s', esc_url( $url ), esc_attr( $css_class ), + $aria_attribute, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $target_attribute, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $rel_attribute, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped esc_html( $label ) ); } else { printf( - '', + '', esc_attr( $css_class ), + $aria_attribute, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped esc_html( $label ) ); } diff --git a/src/components/button/button.scss b/src/components/button/button.scss index a80b1c42..7f16fda7 100644 --- a/src/components/button/button.scss +++ b/src/components/button/button.scss @@ -2,6 +2,8 @@ display: inline-flex; align-items: center; justify-content: center; + gap: 0.5rem; + min-height: 2.75rem; padding: 0.75rem 1.5rem; font-size: 1rem; font-weight: 500; @@ -20,4 +22,48 @@ text-decoration: none; color: #fff; } + + &:focus-visible { + outline: 2px solid #111827; + outline-offset: 3px; + } + + &--secondary { + color: #111827; + background-color: #fff; + border-color: #9ca3af; + + &:hover, + &:focus { + color: #111827; + background-color: #f3f4f6; + } + } + + &--text { + min-height: auto; + padding: 0; + color: #005177; + background-color: transparent; + border-color: transparent; + + &:hover, + &:focus { + color: #003f5c; + background-color: transparent; + text-decoration: underline; + } + } + + &--small { + min-height: 2.25rem; + padding: 0.5rem 1rem; + font-size: 0.875rem; + } + + &--large { + min-height: 3.25rem; + padding: 0.875rem 1.75rem; + font-size: 1.125rem; + } } diff --git a/src/components/card/card.js b/src/components/card/card.js index 071fd22a..3d4bf817 100644 --- a/src/components/card/card.js +++ b/src/components/card/card.js @@ -1,9 +1,4 @@ /** * Card component script. */ -document.addEventListener('DOMContentLoaded', () => { - const cards = document.querySelectorAll('.elementary-card'); - if (cards.length > 0) { - console.log(`Elementary Card component loaded. Found ${cards.length} cards.`); - } -}); +document.documentElement.classList.add( 'has-elementary-card-component' ); diff --git a/src/components/card/card.php b/src/components/card/card.php index 8e0cd48e..afb01d31 100644 --- a/src/components/card/card.php +++ b/src/components/card/card.php @@ -11,29 +11,35 @@ * @param array $args { * Component arguments. * - * @type string $title Card title. Required. - * @type string $description Card description text. Optional. - * @type string $image_url Card image URL. Optional. - * @type string $url Card link URL. Optional. + * @type string $title Card title. Required. + * @type string $description Card description text. Optional. + * @type string $image_url Card image URL. Optional. + * @type string $image_alt Card image alt text. Optional. + * @type string $url Card link URL. Optional. + * @type string $button_label Card action label. Optional. + * @type string $class Additional CSS classes. Optional. * } */ -use rtCamp\Theme\Elementary\Framework\ComponentLoader; - -$title = $args['title'] ?? ''; -$description = $args['description'] ?? ''; -$image_url = $args['image_url'] ?? ''; -$url = $args['url'] ?? ''; +$title = isset( $args['title'] ) ? (string) $args['title'] : ''; +$description = isset( $args['description'] ) ? (string) $args['description'] : ''; +$image_url = isset( $args['image_url'] ) ? (string) $args['image_url'] : ''; +$image_alt = isset( $args['image_alt'] ) ? (string) $args['image_alt'] : ''; +$url = isset( $args['url'] ) ? (string) $args['url'] : ''; +$button_label = isset( $args['button_label'] ) ? (string) $args['button_label'] : __( 'Read more', 'elementary-theme' ); +$class = isset( $args['class'] ) ? (string) $args['class'] : ''; if ( empty( $title ) ) { return; } +$css_class = trim( 'elementary-card ' . $class ); + ?> -
+
- <?php echo esc_attr( $title ); ?> + <?php echo esc_attr( $image_alt ); ?>
@@ -44,20 +50,26 @@

- -
- $title, - 'url' => $url, - 'class' => 'elementary-card__button', - ] - ); - ?> -
- + +
+ $button_label, + 'url' => $url, + 'class' => 'elementary-card__button', + 'variant' => 'secondary', + 'aria_label' => sprintf( + /* translators: %s: Card title. */ + __( 'Read more about %s', 'elementary-theme' ), + $title + ), + ] + ); + ?> +
+
- + +
+
+

+ + +

+ + + +
+ $button_label, + 'url' => $button_url, + 'class' => 'elementary-hero__button', + ] + ); + ?> +
+ +
+
diff --git a/src/components/hero/hero.scss b/src/components/hero/hero.scss new file mode 100644 index 00000000..e6f97bbb --- /dev/null +++ b/src/components/hero/hero.scss @@ -0,0 +1,29 @@ +.elementary-hero { + padding: 4rem 1.5rem; + background-color: #f8fafc; + border-bottom: 1px solid #e5e7eb; + + &__content { + max-width: 48rem; + margin: 0 auto; + text-align: center; + } + + &__title { + margin: 0; + font-size: 3rem; + line-height: 1.1; + color: #111827; + } + + &__subtitle { + margin: 1rem 0 0; + font-size: 1.25rem; + line-height: 1.6; + color: #4b5563; + } + + &__action { + margin-top: 2rem; + } +} diff --git a/src/components/navigation/navigation.js b/src/components/navigation/navigation.js new file mode 100644 index 00000000..1ec48271 --- /dev/null +++ b/src/components/navigation/navigation.js @@ -0,0 +1,4 @@ +/** + * Navigation component script. + */ +document.documentElement.classList.add( 'has-elementary-navigation-component' ); diff --git a/src/components/navigation/navigation.php b/src/components/navigation/navigation.php new file mode 100644 index 00000000..17a49a17 --- /dev/null +++ b/src/components/navigation/navigation.php @@ -0,0 +1,49 @@ + $items Navigation items. Each item accepts label, url, current. + * @type string $class Additional CSS classes. Optional. + * } + */ + +$label = isset( $args['label'] ) ? (string) $args['label'] : __( 'Primary navigation', 'elementary-theme' ); +$items = isset( $args['items'] ) && is_array( $args['items'] ) ? $args['items'] : []; +$class = isset( $args['class'] ) ? (string) $args['class'] : ''; + +if ( empty( $items ) ) { + return; +} + +$css_class = trim( 'elementary-navigation ' . $class ); +?> + diff --git a/src/components/navigation/navigation.scss b/src/components/navigation/navigation.scss new file mode 100644 index 00000000..1820e89a --- /dev/null +++ b/src/components/navigation/navigation.scss @@ -0,0 +1,34 @@ +.elementary-navigation { + + &__list { + display: flex; + flex-wrap: wrap; + gap: 0.75rem 1.25rem; + margin: 0; + padding: 0; + list-style: none; + } + + &__link { + display: inline-flex; + align-items: center; + min-height: 2.5rem; + color: #111827; + text-decoration: none; + + &:hover, + &:focus { + color: #005177; + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid #111827; + outline-offset: 3px; + } + + &[aria-current="page"] { + font-weight: 700; + } + } +} diff --git a/src/components/postloop/postloop.js b/src/components/postloop/postloop.js new file mode 100644 index 00000000..b216190a --- /dev/null +++ b/src/components/postloop/postloop.js @@ -0,0 +1,4 @@ +/** + * Post Loop component script. + */ +document.documentElement.classList.add( 'has-elementary-post-loop-component' ); diff --git a/src/components/postloop/postloop.php b/src/components/postloop/postloop.php new file mode 100644 index 00000000..9b949a3a --- /dev/null +++ b/src/components/postloop/postloop.php @@ -0,0 +1,48 @@ + $items Prepared post items. Each item accepts title, url, excerpt. + * @type string $emptyMessage Empty state message. Optional. + * @type string $class Additional CSS classes. Optional. + * } + */ + +$items = isset( $args['items'] ) && is_array( $args['items'] ) ? $args['items'] : []; +$empty_message = isset( $args['emptyMessage'] ) ? (string) $args['emptyMessage'] : __( 'No posts found.', 'elementary-theme' ); +$class = isset( $args['class'] ) ? (string) $args['class'] : ''; +$css_class = trim( 'elementary-post-loop ' . $class ); +?> +
+ +

+ +
    + + +
  • + + + + + +

    + +
  • + +
+ +
diff --git a/src/components/postloop/postloop.scss b/src/components/postloop/postloop.scss new file mode 100644 index 00000000..f9d8a29b --- /dev/null +++ b/src/components/postloop/postloop.scss @@ -0,0 +1,35 @@ +.elementary-post-loop { + + &__list { + display: grid; + gap: 1rem; + margin: 0; + padding: 0; + list-style: none; + } + + &__item { + padding-bottom: 1rem; + border-bottom: 1px solid #e5e7eb; + } + + &__link { + font-size: 1.125rem; + font-weight: 700; + color: #111827; + text-decoration: none; + + &:hover, + &:focus { + color: #005177; + text-decoration: underline; + } + } + + &__excerpt, + &__empty-message { + margin: 0.5rem 0 0; + color: #4b5563; + line-height: 1.6; + } +} diff --git a/tests/php/inc/Modules/Blocks/RegistrarTest.php b/tests/php/inc/Modules/Blocks/RegistrarTest.php new file mode 100644 index 00000000..4ef9d8e1 --- /dev/null +++ b/tests/php/inc/Modules/Blocks/RegistrarTest.php @@ -0,0 +1,87 @@ +assertTrue( $registry->is_registered( 'rtcamp/button' ), 'rtcamp/button should be registered.' ); + $this->assertTrue( $registry->is_registered( 'rtcamp/card' ), 'rtcamp/card should be registered.' ); + $this->assertTrue( $registry->is_registered( 'rtcamp/hero' ), 'rtcamp/hero should be registered.' ); + $this->assertTrue( $registry->is_registered( 'rtcamp/navigation' ), 'rtcamp/navigation should be registered.' ); + $this->assertTrue( $registry->is_registered( 'rtcamp/post-loop' ), 'rtcamp/post-loop should be registered.' ); + } + + /** + * Test that the legacy block paths filter remains supported. + */ + public function test_legacy_block_paths_filter_registers_extra_blocks(): void { + $registry = WP_Block_Type_Registry::get_instance(); + $library_root = sys_get_temp_dir() . '/elementary-legacy-blocks-' . wp_generate_uuid4(); + $blocks_root = $library_root . '/blocks'; + $block_root = $blocks_root . '/legacy-filter-probe'; + + wp_mkdir_p( $block_root ); + + file_put_contents( // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + $block_root . '/block.json', + wp_json_encode( + [ + '$schema' => 'https://schemas.wp.org/trunk/block.json', + 'apiVersion' => 3, + 'name' => 'rtcamp/legacy-filter-probe', + 'title' => 'Legacy Filter Probe', + 'render' => 'file:./render.php', + ] + ) + ); + file_put_contents( // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + $block_root . '/render.php', + '";' + ); + + $paths_callback = static function ( array $paths ) use ( $blocks_root ): array { + $paths[] = $blocks_root; + + return $paths; + }; + + add_filter( 'elementary_theme_block_paths', $paths_callback ); + + try { + Registrar::get_instance()->register_blocks(); + + $this->assertTrue( $registry->is_registered( 'rtcamp/legacy-filter-probe' ) ); + } finally { + remove_filter( 'elementary_theme_block_paths', $paths_callback ); + unregister_block_type( 'rtcamp/legacy-filter-probe' ); + unlink( $block_root . '/render.php' ); + unlink( $block_root . '/block.json' ); + rmdir( $block_root ); + rmdir( $blocks_root ); + rmdir( $library_root ); + } + } + +} diff --git a/tests/php/src/Blocks/BlockRenderTest.php b/tests/php/src/Blocks/BlockRenderTest.php new file mode 100644 index 00000000..a471c239 --- /dev/null +++ b/tests/php/src/Blocks/BlockRenderTest.php @@ -0,0 +1,143 @@ + 'Block Button', + 'url' => 'https://example.com/block', + 'variant' => 'secondary', + 'class' => 'custom-block-class', + ]; + + $output = render_block( + [ + 'blockName' => 'rtcamp/button', + 'attrs' => $attributes, + ] + ); + + $this->assertStringContainsString( 'Block Button', $output ); + $this->assertStringContainsString( 'https://example.com/block', $output ); + $this->assertStringContainsString( 'elementary-button--secondary', $output ); + $this->assertStringContainsString( 'custom-block-class', $output ); + } + + /** + * Test rtcamp/card render callback. + */ + public function test_card_block_render(): void { + $output = render_block( + [ + 'blockName' => 'rtcamp/card', + 'attrs' => [ + 'title' => 'Block Card', + 'description' => 'Block card description.', + 'url' => 'https://example.com/card', + ], + ] + ); + + $this->assertStringContainsString( 'Block Card', $output ); + $this->assertStringContainsString( 'Block card description.', $output ); + $this->assertStringContainsString( 'elementary-card', $output ); + } + + /** + * Test rtcamp/hero render callback. + */ + public function test_hero_block_render(): void { + $attributes = [ + 'title' => 'My Hero', + 'subtitle' => 'Hero Subtitle', + ]; + + $output = render_block( + [ + 'blockName' => 'rtcamp/hero', + 'attrs' => $attributes, + ] + ); + + $this->assertStringContainsString( 'My Hero', $output ); + $this->assertStringContainsString( 'Hero Subtitle', $output ); + $this->assertStringContainsString( 'elementary-hero', $output ); + } + + /** + * Test rtcamp/navigation render callback. + */ + public function test_navigation_block_render(): void { + $output = render_block( + [ + 'blockName' => 'rtcamp/navigation', + 'attrs' => [ + 'label' => 'Footer navigation', + 'items' => [ + [ + 'label' => 'Contact', + 'url' => 'https://example.com/contact', + 'current' => true, + ], + ], + ], + ] + ); + + $this->assertStringContainsString( 'Footer navigation', $output ); + $this->assertStringContainsString( 'Contact', $output ); + $this->assertStringContainsString( 'aria-current="page"', $output ); + } + + /** + * Test rtcamp/post-loop render callback. + */ + public function test_post_loop_block_render(): void { + // Test empty state. + $output = render_block( + [ + 'blockName' => 'rtcamp/post-loop', + 'attrs' => [ + 'emptyMessage' => 'No custom posts found.', + ], + ] + ); + + $this->assertStringContainsString( 'No custom posts found.', $output ); + + // Create a post and test non-empty state. + self::factory()->post->create( [ 'post_title' => 'Test Post Loop' ] ); + + $output = render_block( + [ + 'blockName' => 'rtcamp/post-loop', + 'attrs' => [ + 'postsPerPage' => 1, + ], + ] + ); + + $this->assertStringContainsString( 'Test Post Loop', $output ); + $this->assertStringContainsString( 'elementary-post-loop', $output ); + } + +} diff --git a/tests/php/src/Components/ComponentTest.php b/tests/php/src/Components/ComponentTest.php new file mode 100644 index 00000000..e99a102d --- /dev/null +++ b/tests/php/src/Components/ComponentTest.php @@ -0,0 +1,132 @@ + 'Click Me', + 'url' => 'https://example.com', + ] + ); + + $this->assertStringContainsString( 'Click Me', $output ); + $this->assertStringContainsString( 'https://example.com', $output ); + $this->assertStringContainsString( ' 'Submit' ] ); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'Submit', $output ); + $this->assertStringContainsString( ' 'Card Title', + 'description' => 'Card description.', + 'url' => 'https://example.com/card', + 'button_label' => 'Explore', + ] + ); + + $this->assertStringContainsString( 'Card Title', $output ); + $this->assertStringContainsString( 'Card description.', $output ); + $this->assertStringContainsString( 'Explore', $output ); + } + + /** + * Test Hero component. + */ + public function test_hero_component(): void { + $output = elementary_theme_get_component( + 'Hero', + [ + 'title' => 'Hero Title', + 'subtitle' => 'Hero subtitle.', + 'buttonLabel' => 'Start', + 'buttonUrl' => 'https://example.com/start', + ] + ); + + $this->assertStringContainsString( 'Hero Title', $output ); + $this->assertStringContainsString( 'Hero subtitle.', $output ); + $this->assertStringContainsString( 'Start', $output ); + } + + /** + * Test Navigation component. + */ + public function test_navigation_component(): void { + $output = elementary_theme_get_component( + 'Navigation', + [ + 'label' => 'Utility navigation', + 'items' => [ + [ + 'label' => 'Docs', + 'url' => 'https://example.com/docs', + 'current' => true, + ], + ], + ] + ); + + $this->assertStringContainsString( 'Utility navigation', $output ); + $this->assertStringContainsString( 'Docs', $output ); + $this->assertStringContainsString( 'aria-current="page"', $output ); + } + + /** + * Test PostLoop component with prepared data. + */ + public function test_post_loop_component_uses_prepared_items(): void { + $output = elementary_theme_get_component( + 'PostLoop', + [ + 'items' => [ + [ + 'title' => 'Prepared Post', + 'url' => 'https://example.com/prepared-post', + 'excerpt' => 'Prepared excerpt.', + ], + ], + ] + ); + + $this->assertStringContainsString( 'Prepared Post', $output ); + $this->assertStringContainsString( 'Prepared excerpt.', $output ); + $this->assertStringNotContainsString( 'WP_Query', $output ); + } +} diff --git a/webpack.config.js b/webpack.config.js index 9807e8cc..3aaffb33 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -138,6 +138,10 @@ const sharedConfig = { }, plugins: [ ...scriptConfig.plugins + .filter( + ( plugin ) => + ! [ 'CopyPlugin', 'CopyWebpackPlugin' ].includes( plugin.constructor.name ), + ) .map( ( plugin ) => { if ( plugin.constructor.name === 'MiniCssExtractPlugin' ) {