diff --git a/README.md b/README.md index c89942c..7232ea6 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,66 @@ # Blockparty FAQ +A Gutenberg block for SEO friendly FAQ in an accessible accordion. + +## Development Setup + +### Prerequisites + +- Node.js 20.12.0 (managed by Volta) +- Docker (for wp-env) + +### Installation + +1. Clone the repository +2. Install dependencies: + + ```bash + npm install + ``` + +3. Build the blocks: + + ```bash + npm run build + ``` + +4. Start the WordPress environment and install Yoast SEO: + + ```bash + npm run setup:env + ``` + + **Note:** On Windows, you may need to run the commands separately: + + ```bash + npm run start:env + # Wait for WordPress to be ready (about 10-15 seconds) + npm run setup + ``` + +### Available Scripts + +- `npm run build` - Build the blocks for production +- `npm run start` - Start the development server with hot reload +- `npm run start:env` - Start the WordPress environment (wp-env) +- `npm run stop:env` - Stop the WordPress environment +- `npm run install:yoast` - Install and activate Yoast SEO plugin (required for schema generation) +- `npm run setup:env` - Start wp-env and install Yoast SEO in one command + +### Note + +Yoast SEO is required for the FAQ schema (JSON-LD) generation. It is installed automatically via `npm run setup:env` but is not versioned in the repository. + ## Changelog + ### 1.0.0 - 2024-04-02 + * initial commit. ### 1.0.1 - 2024-04-03 + * fix css variable names ### 1.0.2 - 2024-06-06 -* Add support for PHP 8.2 \ No newline at end of file + +* Add support for PHP 8.2 diff --git a/block.json b/block.json deleted file mode 100644 index a511d4c..0000000 --- a/block.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "apiVersion": 2, - "name": "blockparty/faq", - "version": "1.0.2", - "title": "Faq", - "category": "design", - "icon": "format-chat", - "description": "SEO friendly FAQ module in an accessible accordion", - "supports": { - "html": false - }, - "attributes": { - "questions": { - "type": "array", - "default": [] - } - }, - "textdomain": "blockparty-faq", - "editorScript": "file:build/index.js", - "editorStyle": "file:build/index.css", - "style": "file:./build/style-index.css" -} diff --git a/blockparty-faq.php b/blockparty-faq.php index 265d534..34569ea 100644 --- a/blockparty-faq.php +++ b/blockparty-faq.php @@ -1,7 +1,7 @@ apply_filters( + 'beapi_faq_block_config', + [ + 'allowMultiple' => true, + 'closedDefault' => true, + 'forceExpand' => false, + 'hasAnimation' => true, + 'openMultiple' => false, + 'panelSelector' => '.faq__panel', + 'prefixId' => 'block-faq', + 'triggerSelector' => '.faq__trigger', + ] + ), + ]; + + wp_localize_script( 'blockparty-faq-view-script', 'beapiFaqBlock', $constants ); + + do_action( 'blockparty_faq_init' ); } add_action( 'init', __NAMESPACE__ . '\\blockparty_faq_init' ); diff --git a/composer.json b/composer.json index 264a27f..894b89e 100644 --- a/composer.json +++ b/composer.json @@ -28,7 +28,7 @@ } }, "require": { - "php": "^8.1 | ^8.2", + "php": "^8.1 | ^8.2 | ^8.3 | ^8.4", "ext-json": "*", "composer/installers": "^1.0 || ^2.0" }, diff --git a/includes/schema/faq_schema.php b/includes/schema/faq_schema.php index 7d6f68e..8a15faa 100644 --- a/includes/schema/faq_schema.php +++ b/includes/schema/faq_schema.php @@ -47,17 +47,45 @@ public function is_needed(): bool { private function generate_ids(): array { $ids = []; foreach ( $this->context->blocks['blockparty/faq'] as $block ) { - if ( empty( $block['attrs']['questions'] ) ) { + if ( empty( $block['innerBlocks'] ) ) { continue; } - $all_faqs_id = wp_list_pluck( $block['attrs']['questions'], 'id' ); - if ( empty( $all_faqs_id ) ) { - continue; - } + // Process faq-item blocks + foreach ( $block['innerBlocks'] as $item_block ) { + if ( 'blockparty/faq-item' !== $item_block['blockName'] ) { + continue; + } + + if ( empty( $item_block['innerBlocks'] ) ) { + continue; + } + + // Find question block in faq-item + $question_block = null; + foreach ( $item_block['innerBlocks'] as $inner_block ) { + if ( 'blockparty/faq-question' === $inner_block['blockName'] ) { + $question_block = $inner_block; + break; + } + } + + if ( ! $question_block ) { + continue; + } - foreach ( $all_faqs_id as $question_id ) { - $ids[] = [ '@id' => $this->context->canonical . '#' . \esc_attr( $question_id ) ]; + // Get question text from attributes or InnerBlocks + $question_text = $question_block['attrs']['question'] ?? ''; + + // If question is empty, try to get it from InnerBlocks (when isAccordion is false) + if ( empty( $question_text ) && ! empty( $question_block['innerBlocks'] ) ) { + $question_text = $this->get_question_content( $question_block ); + } + + $question_id = ! empty( $question_text ) + ? md5( $question_text ) + : 'faq-' . uniqid(); + $ids[] = [ '@id' => $this->context->canonical . '#' . \esc_attr( $question_id ) ]; } } @@ -70,47 +98,137 @@ private function generate_ids(): array { * @return array Our Schema graph. */ public function generate(): array { - $graph = []; - $questions = []; + $graph = []; foreach ( $this->context->blocks['blockparty/faq'] as $block ) { - - if ( empty( $block['attrs']['questions'] ) ) { + if ( empty( $block['innerBlocks'] ) ) { continue; } - $questions = array_merge( $questions, $block['attrs']['questions'] ); - } + $position = 1; - foreach ( $questions as $index => $question ) { - if ( empty( $question['answer'] ) ) { - continue; + // Process faq-item blocks + foreach ( $block['innerBlocks'] as $item_block ) { + if ( 'blockparty/faq-item' !== $item_block['blockName'] ) { + continue; + } + + if ( empty( $item_block['innerBlocks'] ) ) { + continue; + } + + // Find question and answer blocks in faq-item + $question_block = null; + $answer_block = null; + + foreach ( $item_block['innerBlocks'] as $inner_block ) { + if ( 'blockparty/faq-question' === $inner_block['blockName'] ) { + $question_block = $inner_block; + } elseif ( 'blockparty/faq-answer' === $inner_block['blockName'] ) { + $answer_block = $inner_block; + } + } + + // Skip if no question block + if ( ! $question_block ) { + continue; + } + + // Get question text from attributes or InnerBlocks + $question_text = $question_block['attrs']['question'] ?? ''; + + // If question is empty, try to get it from InnerBlocks (when isAccordion is false) + if ( empty( $question_text ) && ! empty( $question_block['innerBlocks'] ) ) { + $question_text = $this->get_question_content( $question_block ); + } + + if ( empty( $question_text ) ) { + continue; + } + + // Get answer content from InnerBlocks + $answer_content = ''; + if ( $answer_block ) { + $answer_content = $this->get_answer_content( $answer_block ); + } + + if ( empty( $answer_content ) ) { + continue; + } + + $question_id = md5( $question_text ); + $graph[] = $this->generate_question_block( + $question_text, + $answer_content, + $question_id, + $position + ); + ++$position; } - $graph[] = $this->generate_question_block( $question, ( $index + 1 ) ); } return $graph; } + /** + * Get question content from InnerBlocks. + * + * @param array $question_block The question block with InnerBlocks. + * @return string The question content as HTML. + */ + protected function get_question_content( array $question_block ): string { + if ( empty( $question_block['innerBlocks'] ) ) { + return ''; + } + + $content = ''; + foreach ( $question_block['innerBlocks'] as $inner_block ) { + $content .= render_block( $inner_block ); + } + + return wp_strip_all_tags( $content ); + } + + /** + * Get answer content from InnerBlocks. + * + * @param array $answer_block The answer block with InnerBlocks. + * @return string The answer content as HTML. + */ + protected function get_answer_content( array $answer_block ): string { + if ( empty( $answer_block['innerBlocks'] ) ) { + return ''; + } + + $content = ''; + foreach ( $answer_block['innerBlocks'] as $inner_block ) { + $content .= render_block( $inner_block ); + } + + return $content; + } + /** * Generate a Question piece. * - * @param array $question The question data to generate schema for. + * @param string $question_text The question text. + * @param string $answer_content The answer content as HTML. + * @param string $question_id The question ID. * @param int $position The position of the question. * * @return array Schema.org Question piece. */ - protected function generate_question_block( array $question, int $position ): array { - $url = $this->context->canonical . '#' . \esc_attr( $question['id'] ); + protected function generate_question_block( string $question_text, string $answer_content, string $question_id, int $position ): array { + $url = $this->context->canonical . '#' . \esc_attr( $question_id ); $data = [ '@type' => 'Question', '@id' => $url, 'position' => $position, 'url' => $url, - 'name' => esc_html( $question['question'] ), + 'name' => esc_html( $question_text ), 'answerCount' => 1, - 'acceptedAnswer' => $this->add_accepted_answer_property( $question['answer'] ), + 'acceptedAnswer' => $this->add_accepted_answer_property( $answer_content ), ]; $data['inLanguage'] = get_bloginfo( 'language' ); @@ -126,9 +244,54 @@ protected function generate_question_block( array $question, int $position ): ar * @return array Schema.org Question piece. */ protected function add_accepted_answer_property( string $answer ): array { + // Allowed HTML elements and attributes for Schema.org FAQPage acceptedAnswer text property. + // Supports: headings, paragraphs, lists, formatting, links, quotes, images, and structure. + $allowed_html = [ + 'h1' => [], + 'h2' => [], + 'h3' => [], + 'h4' => [], + 'h5' => [], + 'h6' => [], + 'p' => [], + 'br' => [], + 'ol' => [], + 'ul' => [], + 'li' => [], + 'a' => [ + 'href' => [], + 'title' => [], + ], + 'b' => [], + 'strong' => [], + 'i' => [], + 'em' => [], + 'blockquote' => [ + 'cite' => [], + ], + 'cite' => [], + 'q' => [ + 'cite' => [], + ], + 'img' => [ + 'src' => [], + 'alt' => [], + 'title' => [], + 'width' => [], + 'height' => [], + ], + 'div' => [ + 'class' => [], + ], + 'span' => [ + 'class' => [], + ], + 'hr' => [], + ]; + $data = [ '@type' => 'Answer', - 'text' => wp_strip_all_tags( wp_unslash( $answer ), '


+
+ +
+ + ) ) } + + + ); +} + +/** + * Deprecated block configuration for migration from old format. + * + * Old format: questions array in attributes + * New format: InnerBlocks with faq-item blocks + */ +const deprecated = [ + { + attributes: { + questions: { + type: 'array', + default: [], + }, + isAccordion: { + type: 'boolean', + default: true, + }, + }, + supports: { + html: false, + innerBlocks: true, + }, + isEligible, + migrate, + save: deprecatedSave, + }, +]; + +export default deprecated; diff --git a/src/faq/edit.js b/src/faq/edit.js new file mode 100644 index 0000000..6016e58 --- /dev/null +++ b/src/faq/edit.js @@ -0,0 +1,101 @@ +/** + * WordPress dependencies + */ +import { + BlockControls, + InnerBlocks, + useBlockProps, + InspectorControls, +} from '@wordpress/block-editor'; +import { + ToolbarGroup, + ToolbarButton, + PanelBody, + ToggleControl, +} from '@wordpress/components'; +import { addCard } from '@wordpress/icons'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { createBlock } from '@wordpress/blocks'; +import { __ } from '@wordpress/i18n'; +import { useEffect } from '@wordpress/element'; + +export default function Edit( { clientId, attributes, setAttributes } ) { + const { isAccordion = true } = attributes; + const blockProps = useBlockProps(); + + const { insertBlock, updateBlockAttributes } = + useDispatch( 'core/block-editor' ); + const { getBlocks } = useSelect( + ( select ) => select( 'core/block-editor' ), + [] + ); + + // Synchronize isAccordion attribute to all child blocks + useEffect( () => { + const innerBlocks = getBlocks( clientId ); + innerBlocks.forEach( ( block ) => { + // Update faq-item blocks + if ( 'blockparty/faq-item' === block.name ) { + const itemInnerBlocks = getBlocks( block.clientId ); + itemInnerBlocks.forEach( ( itemBlock ) => { + if ( + ( 'blockparty/faq-question' === itemBlock.name || + 'blockparty/faq-answer' === itemBlock.name ) && + itemBlock.attributes.isAccordion !== isAccordion + ) { + updateBlockAttributes( itemBlock.clientId, { + isAccordion: isAccordion, + } ); + } + } ); + } + } ); + }, [ isAccordion, clientId, getBlocks, updateBlockAttributes ] ); + + const onAddItem = () => { + const newItem = createBlock( 'blockparty/faq-item', {}, [ + createBlock( 'blockparty/faq-question', { isAccordion } ), + createBlock( 'blockparty/faq-answer', { isAccordion } ), + ] ); + insertBlock( newItem, undefined, clientId ); + }; + + return ( + <> + + + + + + + + + setAttributes( { isAccordion: value } ) + } + __nextHasNoMarginBottom + /> + + +
+
+ +
+
+ + ); +} diff --git a/src/editor.scss b/src/faq/editor.scss similarity index 100% rename from src/editor.scss rename to src/faq/editor.scss diff --git a/src/faq/index.js b/src/faq/index.js new file mode 100644 index 0000000..7689e23 --- /dev/null +++ b/src/faq/index.js @@ -0,0 +1,40 @@ +/** + * WordPress dependencies + */ +import { registerBlockType } from '@wordpress/blocks'; +import { help } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import './style.scss'; +import './editor.scss'; +import Edit from './edit'; +import save from './save'; +import deprecated from './deprecated'; +import metadata from './block.json'; + +// Register child blocks first +import '../faq-item'; + +// Register parent block +registerBlockType( metadata.name, { + ...metadata, + /** + * @see ./edit.js + */ + edit: Edit, + + /** + * @see ./save.js + */ + save, + + icon: help, + + /** + * Migration from old format (questions array) to new format (InnerBlocks) + * @see ./deprecated.js + */ + deprecated, +} ); diff --git a/src/faq/save.js b/src/faq/save.js new file mode 100644 index 0000000..f58f43b --- /dev/null +++ b/src/faq/save.js @@ -0,0 +1,17 @@ +/** + * WordPress dependencies + */ +import { useBlockProps } from '@wordpress/block-editor'; +import { InnerBlocks } from '@wordpress/block-editor'; + +export default function save() { + const blockProps = useBlockProps.save(); + + return ( +
+
+ +
+
+ ); +} diff --git a/src/faq/script.js b/src/faq/script.js new file mode 100644 index 0000000..9990707 --- /dev/null +++ b/src/faq/script.js @@ -0,0 +1,12 @@ +import { Accordion } from '@beapi/be-a11y'; + +// eslint-disable-next-line no-undef +const accordionConfig = beapiFaqBlock.accordionConfig; + +// Initialize beapi-accordion +window.addEventListener( 'load', function () { + Accordion.init( + '.wp-block-blockparty-faq:has(button.faq__trigger)', + accordionConfig + ); +} ); diff --git a/src/style.scss b/src/faq/style.scss similarity index 100% rename from src/style.scss rename to src/faq/style.scss diff --git a/src/index.js b/src/index.js index bf8a76d..78bd1c0 100644 --- a/src/index.js +++ b/src/index.js @@ -1,17 +1,11 @@ -import { registerBlockType } from '@wordpress/blocks'; -import './style.scss'; -import './editor.scss'; -import Edit from './edit'; -import save from './save'; +/** + * WordPress dependencies + */ -registerBlockType( 'blockparty/faq', { - /** - * @see ./edit.js - */ - edit: Edit, - - /** - * @see ./save.js - */ - save, -} ); +/** + * Internal dependencies + */ +import './faq'; +import './faq-item'; +import './faq-question'; +import './faq-answer'; diff --git a/src/save.js b/src/save.js deleted file mode 100644 index d93cb30..0000000 --- a/src/save.js +++ /dev/null @@ -1,31 +0,0 @@ -import { RichText, useBlockProps } from '@wordpress/block-editor'; - -export default function save( { attributes } ) { - return ( -
-
- { attributes.questions.map( ( item ) => ( -
-

- -

-
- -
-
- ) ) } -
-
- ); -}