From 2f5387ee385390e3d95b4b827e93f887e6bedbf5 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 1 Sep 2026 10:01:17 -0400 Subject: [PATCH 01/10] Drive carousel behavior through the Interactivity API --- .../AuthoredCarouselBlockGenerator.php | 125 +++++++++++------- php-transformer/tests/contract/run.php | 5 +- .../tests/unit/authored-carousel-block.php | 6 +- 3 files changed, 87 insertions(+), 49 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/Generators/AuthoredCarouselBlockGenerator.php b/php-transformer/src/HtmlToBlocks/Generators/AuthoredCarouselBlockGenerator.php index a9892d4d..ecccf3da 100644 --- a/php-transformer/src/HtmlToBlocks/Generators/AuthoredCarouselBlockGenerator.php +++ b/php-transformer/src/HtmlToBlocks/Generators/AuthoredCarouselBlockGenerator.php @@ -43,49 +43,78 @@ function rootProps( attributes ) { } ); } )( window.wp.blocks, window.wp.blockEditor, window.wp.element ); JS; + // The Interactivity API is WordPress's own front-end runtime for blocks, + // so the behavior is declared on the markup and the module carries only + // the state the directives read. $view = <<<'JS' -( function() { - function mount( root ) { - if ( root.dataset.carouselMounted ) return; - var viewport = root.querySelector( '.blocks-engine-authored-carousel__viewport' ); - var track = root.querySelector( '.blocks-engine-authored-carousel__track' ); - var previous = root.querySelector( '[data-carousel-previous]' ); - var next = root.querySelector( '[data-carousel-next]' ); - var status = root.querySelector( '.blocks-engine-authored-carousel__status' ); - if ( ! viewport || ! track || ! previous || ! next ) return; - var slides = Array.prototype.slice.call( track.children ); - if ( slides.length < 2 ) return; - var index = 0; - var wraps = 'false' !== root.dataset.wrap; - var reducedMotion = window.matchMedia && window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches; - function visibleItems() { - var width = slides[ 0 ].getBoundingClientRect().width; - return width > 0 ? Math.max( 1, Math.min( slides.length, Math.round( viewport.clientWidth / width ) ) ) : 1; - } - function maximumIndex() { return Math.max( 0, slides.length - visibleItems() ); } - function update() { - var maximum = maximumIndex(); - index = Math.min( index, maximum ); - previous.disabled = 0 === maximum || ( ! wraps && 0 === index ); - next.disabled = 0 === maximum || ( ! wraps && index === maximum ); - if ( status ) status.textContent = 'Slide ' + ( index + 1 ) + ' of ' + slides.length; - } - function show( requested ) { - var maximum = maximumIndex(); - index = wraps ? ( requested < 0 ? maximum : requested > maximum ? 0 : requested ) : Math.max( 0, Math.min( maximum, requested ) ); - viewport.scrollTo( { left: slides[ index ].offsetLeft, behavior: reducedMotion ? 'auto' : 'smooth' } ); - update(); - } - previous.addEventListener( 'click', function() { show( index - 1 ); } ); - next.addEventListener( 'click', function() { show( index + 1 ); } ); - viewport.addEventListener( 'keydown', function( event ) { if ( 'ArrowLeft' === event.key || 'ArrowRight' === event.key ) { event.preventDefault(); show( index + ( 'ArrowLeft' === event.key ? -1 : 1 ) ); } } ); - if ( window.ResizeObserver ) new ResizeObserver( update ).observe( viewport ); - root.dataset.carouselMounted = 'true'; - update(); +import { store, getContext, getElement } from '@wordpress/interactivity'; + +const slidesOf = ( ref ) => Array.from( ref.querySelectorAll( '.blocks-engine-authored-carousel__track > *' ) ); + +const visibleCount = ( ref ) => { + const viewport = ref.querySelector( '.blocks-engine-authored-carousel__viewport' ); + const slides = slidesOf( ref ); + if ( ! viewport || 0 === slides.length ) { + return 1; } - function mountAll() { document.querySelectorAll( '.blocks-engine-authored-carousel' ).forEach( mount ); } - if ( 'loading' === document.readyState ) document.addEventListener( 'DOMContentLoaded', mountAll ); else mountAll(); -} )(); + const width = slides[ 0 ].getBoundingClientRect().width; + return width > 0 ? Math.max( 1, Math.min( slides.length, Math.round( viewport.clientWidth / width ) ) ) : 1; +}; + +const maximumIndex = ( ref ) => Math.max( 0, slidesOf( ref ).length - visibleCount( ref ) ); + +const show = ( requested ) => { + const context = getContext(); + const { ref } = getElement(); + const maximum = maximumIndex( ref ); + context.index = context.wrap + ? ( requested < 0 ? maximum : requested > maximum ? 0 : requested ) + : Math.max( 0, Math.min( maximum, requested ) ); + const viewport = ref.querySelector( '.blocks-engine-authored-carousel__viewport' ); + const slide = slidesOf( ref )[ context.index ]; + if ( viewport && slide ) { + viewport.scrollTo( { + left: slide.offsetLeft, + behavior: window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches ? 'auto' : 'smooth', + } ); + } +}; + +store( 'blocks-engine/carousel', { + state: { + get atStart() { + const context = getContext(); + const { ref } = getElement(); + return 0 === maximumIndex( ref ) || ( ! context.wrap && 0 === context.index ); + }, + get atEnd() { + const context = getContext(); + const { ref } = getElement(); + const maximum = maximumIndex( ref ); + return 0 === maximum || ( ! context.wrap && context.index === maximum ); + }, + get statusText() { + const context = getContext(); + const { ref } = getElement(); + return 'Slide ' + ( context.index + 1 ) + ' of ' + slidesOf( ref ).length; + }, + }, + actions: { + previous() { + show( getContext().index - 1 ); + }, + next() { + show( getContext().index + 1 ); + }, + keydown( event ) { + if ( 'ArrowLeft' !== event.key && 'ArrowRight' !== event.key ) { + return; + } + event.preventDefault(); + show( getContext().index + ( 'ArrowLeft' === event.key ? -1 : 1 ) ); + }, + }, +} ); JS; $style = '.blocks-engine-authored-carousel{--blocks-engine-carousel-gap:1rem;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:var(--blocks-engine-carousel-gap);align-items:center;max-width:100%;min-width:0}.blocks-engine-authored-carousel__viewport{min-width:0;overflow:hidden;scroll-behavior:smooth}.blocks-engine-authored-carousel__track{display:grid;grid-auto-flow:column;grid-auto-columns:calc((100% - 3rem)/4);gap:var(--blocks-engine-carousel-gap)}.blocks-engine-authored-carousel--items-1 .blocks-engine-authored-carousel__track{grid-auto-columns:100%}.blocks-engine-authored-carousel--items-2 .blocks-engine-authored-carousel__track{grid-auto-columns:calc((100% - 1rem)/2)}.blocks-engine-authored-carousel--items-3 .blocks-engine-authored-carousel__track{grid-auto-columns:calc((100% - 2rem)/3)}.blocks-engine-authored-carousel--items-5 .blocks-engine-authored-carousel__track{grid-auto-columns:calc((100% - 4rem)/5)}.blocks-engine-authored-carousel--items-6 .blocks-engine-authored-carousel__track{grid-auto-columns:calc((100% - 5rem)/6)}.blocks-engine-authored-carousel__track>*{box-sizing:border-box;min-width:0;margin:0}.blocks-engine-authored-carousel__track>.wp-block-image img{display:block;width:100%;aspect-ratio:3/4;object-fit:cover;border-radius:inherit}.blocks-engine-authored-carousel__previous,.blocks-engine-authored-carousel__next{cursor:pointer}.blocks-engine-authored-carousel__previous:disabled,.blocks-engine-authored-carousel__next:disabled{cursor:default;opacity:.45}.blocks-engine-authored-carousel__status{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:900px){.blocks-engine-authored-carousel .blocks-engine-authored-carousel__track{grid-auto-columns:calc((100% - 1rem)/2)}}@media(max-width:600px){.blocks-engine-authored-carousel .blocks-engine-authored-carousel__track{grid-auto-columns:100%}}@media(prefers-reduced-motion:reduce){.blocks-engine-authored-carousel__viewport{scroll-behavior:auto}}'; @@ -98,10 +127,10 @@ function mountAll() { document.querySelectorAll( '.blocks-engine-authored-carous 'category' => 'media', 'description' => 'An editable carousel with bounded previous and next navigation.', 'editorScript' => 'file:./index.js', - 'viewScript' => 'file:./view.js', + 'viewScriptModule' => 'file:./view.js', 'style' => 'file:./style.css', 'attributes' => $attributes, - 'supports' => array('html' => false, 'customClassName' => false), + 'supports' => array('html' => false, 'customClassName' => false, 'interactivity' => true), ), 'assets' => array( 'index.js' => str_replace(array('__BLOCK_NAME__', '__ATTRIBUTES__'), array($blockName, json_encode($attributes, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)), $editor), @@ -119,9 +148,15 @@ public function shell(array $attributes): array $items = min(6, max(1, (int) ($attributes['itemsPerView'] ?? 4))); $wrap = false === ($attributes['wrap'] ?? true) ? 'false' : 'true'; + $context = htmlspecialchars( + (string) json_encode(array('index' => 0, 'wrap' => 'true' === $wrap), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + ENT_QUOTES | ENT_SUBSTITUTE, + 'UTF-8' + ); + return array( - 'opening' => '', + 'opening' => '', ); } } diff --git a/php-transformer/tests/contract/run.php b/php-transformer/tests/contract/run.php index b0bdb5f9..ccdd9e39 100644 --- a/php-transformer/tests/contract/run.php +++ b/php-transformer/tests/contract/run.php @@ -230,8 +230,9 @@ function serialize_blocks(array $blocks): string $assert( 1 === count($carouselDefinitions) && 'authored-carousel' === ($carouselDefinitions[0]['name'] ?? null) - && 'file:./view.js' === ($carouselDefinitions[0]['block_json']['viewScript'] ?? null) - && str_contains((string) ($carouselDefinitions[0]['view_js'] ?? ''), 'data-carousel-next') + && 'file:./view.js' === ($carouselDefinitions[0]['block_json']['viewScriptModule'] ?? null) + && true === ($carouselDefinitions[0]['block_json']['supports']['interactivity'] ?? null) + && str_contains((string) ($carouselDefinitions[0]['view_js'] ?? ''), "store( 'blocks-engine/carousel'") && isset($carouselDefinitions[0]['assets']['style.css']), 'bounded carousel projection carries one generic editor block with scoped frontend behavior' ); diff --git a/php-transformer/tests/unit/authored-carousel-block.php b/php-transformer/tests/unit/authored-carousel-block.php index aa32b450..fefaa1c0 100644 --- a/php-transformer/tests/unit/authored-carousel-block.php +++ b/php-transformer/tests/unit/authored-carousel-block.php @@ -30,18 +30,20 @@ $editor = (string) ($definition['assets']['index.js'] ?? ''); $view = (string) ($definition['view_js'] ?? ''); $style = (string) ($definition['assets']['style.css'] ?? ''); -$assert('file:./view.js' === ($definition['block_json']['viewScript'] ?? null) && str_contains($editor, 'InnerBlocks.Content'), 'the companion carries one editable parent block and a scoped frontend script'); +$assert('file:./view.js' === ($definition['block_json']['viewScriptModule'] ?? null) && true === ($definition['block_json']['supports']['interactivity'] ?? null) && ! isset($definition['block_json']['viewScript']) && str_contains($editor, 'InnerBlocks.Content'), 'the companion carries one editable parent block and declares its behavior through the Interactivity API'); +$assert(str_contains($view, "from '@wordpress/interactivity'") && str_contains($view, "store( 'blocks-engine/carousel'"), 'frontend behavior is a script module built on the WordPress Interactivity API'); $assert(str_contains($view, "'ArrowLeft'") && str_contains($view, "'ArrowRight'") && str_contains($view, 'requested > maximum ? 0'), 'frontend behavior supports keyboard navigation and deterministic wrapping'); $assert(str_contains($style, 'grid-auto-flow:column') && str_contains($style, '@media(max-width:600px)') && str_contains($style, 'prefers-reduced-motion:reduce'), 'carousel layout is bounded and responsive with reduced-motion handling'); $shell = (new AuthoredCarouselBlockGenerator())->shell(array('ariaLabel' => 'Care & ', 'itemsPerView' => 99, 'wrap' => false)); $shellMarkup = $shell['opening'] . $shell['closing']; $assert(str_contains($shellMarkup, 'aria-label="Care & <support>"') && str_contains($shellMarkup, '--items-6') && str_contains($shellMarkup, 'data-wrap="false"'), 'shell attributes are escaped and bounded'); +$assert(str_contains($shellMarkup, 'data-wp-interactive="blocks-engine/carousel"') && str_contains($shellMarkup, 'data-wp-context="{"index":0,"wrap":false}"') && str_contains($shellMarkup, 'data-wp-on--click="actions.next"') && str_contains($shellMarkup, 'data-wp-bind--disabled="state.atEnd"'), 'the shell declares its behavior through Interactivity API directives'); $payload = (new CompanionPluginPayload())->fromBlockTypes(array(), array(), array(), array($definition)); $payloadBlock = $payload['blocks'][0] ?? array(); $assert(CompanionPluginPayload::SCHEMA === ($payload['schema'] ?? null) && 'authored-carousel' === ($payloadBlock['name'] ?? null), 'the generated carousel uses the established companion-plugin payload'); -$assert(isset($payloadBlock['assets']['index.js'], $payloadBlock['assets']['style.css']) && str_contains((string) ($payloadBlock['view_js'] ?? ''), 'data-carousel-next'), 'the companion payload carries editor, style, and frontend behavior assets'); +$assert(isset($payloadBlock['assets']['index.js'], $payloadBlock['assets']['style.css']) && str_contains((string) ($payloadBlock['view_js'] ?? ''), "store( 'blocks-engine/carousel'"), 'the companion payload carries editor, style, and frontend behavior assets'); $assert(!isset($payloadBlock['render'], $payloadBlock['renderer'], $payloadBlock['block_json']['render']), 'the carousel needs no executable PHP renderer'); $customHost = (new HtmlTransformer())->transform('
')->toArray(); From b174f96948a8885be917a6e547baade4a9b41db1 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 1 Sep 2026 10:25:00 -0400 Subject: [PATCH 02/10] Declare the view module import in the companion payload --- .../src/ArtifactCompiler/CompanionPluginPayload.php | 13 +++++++++++-- .../Generators/AuthoredCarouselBlockGenerator.php | 5 ++++- .../tests/unit/authored-carousel-block.php | 1 + 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/php-transformer/src/ArtifactCompiler/CompanionPluginPayload.php b/php-transformer/src/ArtifactCompiler/CompanionPluginPayload.php index 389291c3..c16f2ecb 100644 --- a/php-transformer/src/ArtifactCompiler/CompanionPluginPayload.php +++ b/php-transformer/src/ArtifactCompiler/CompanionPluginPayload.php @@ -203,7 +203,13 @@ private function normalizeGeneratedBlock(array $block): array if ( is_array($block['assets'] ?? null) && array() !== $block['assets'] ) { $normalized['assets'] = $block['assets']; } - $scriptDependencies = $this->normalizeScriptDependencies($block['script_dependencies'] ?? null, $normalized['assets'] ?? array()); + // The view module is carried in its own slot and lands as `view.js` + // beside the declared assets, so it can be depended on by that name. + $dependencyAssets = $normalized['assets'] ?? array(); + if ( isset($normalized['view_js']) ) { + $dependencyAssets['view.js'] = $normalized['view_js']; + } + $scriptDependencies = $this->normalizeScriptDependencies($block['script_dependencies'] ?? null, $dependencyAssets); if ( array() !== $scriptDependencies ) { $normalized['script_dependencies'] = $scriptDependencies; } @@ -232,7 +238,10 @@ private function normalizeScriptDependencies(mixed $dependencies, array $assets) $validHandles = array(); foreach ( $handles as $handle ) { - if ( ! is_string($handle) || 1 !== preg_match('/^[A-Za-z0-9_-]+$/', $handle) || isset($validHandles[$handle]) ) { + // A classic script states a handle; a script module states its + // import specifier, which is how `@wordpress/interactivity` + // reaches the generated asset manifest. + if ( ! is_string($handle) || 1 !== preg_match('#^(?:@[a-z0-9][a-z0-9._-]*/)?[A-Za-z0-9][A-Za-z0-9._-]*$#', $handle) || isset($validHandles[$handle]) ) { continue; } $validHandles[$handle] = true; diff --git a/php-transformer/src/HtmlToBlocks/Generators/AuthoredCarouselBlockGenerator.php b/php-transformer/src/HtmlToBlocks/Generators/AuthoredCarouselBlockGenerator.php index ecccf3da..1a5ed551 100644 --- a/php-transformer/src/HtmlToBlocks/Generators/AuthoredCarouselBlockGenerator.php +++ b/php-transformer/src/HtmlToBlocks/Generators/AuthoredCarouselBlockGenerator.php @@ -137,7 +137,10 @@ function rootProps( attributes ) { 'style.css' => $style, ), 'view_js' => $view, - 'script_dependencies' => array('index.js' => array('wp-blocks', 'wp-block-editor', 'wp-element')), + 'script_dependencies' => array( + 'index.js' => array('wp-blocks', 'wp-block-editor', 'wp-element'), + 'view.js' => array('@wordpress/interactivity'), + ), ); } diff --git a/php-transformer/tests/unit/authored-carousel-block.php b/php-transformer/tests/unit/authored-carousel-block.php index fefaa1c0..16368439 100644 --- a/php-transformer/tests/unit/authored-carousel-block.php +++ b/php-transformer/tests/unit/authored-carousel-block.php @@ -43,6 +43,7 @@ $payload = (new CompanionPluginPayload())->fromBlockTypes(array(), array(), array(), array($definition)); $payloadBlock = $payload['blocks'][0] ?? array(); $assert(CompanionPluginPayload::SCHEMA === ($payload['schema'] ?? null) && 'authored-carousel' === ($payloadBlock['name'] ?? null), 'the generated carousel uses the established companion-plugin payload'); +$assert(array('@wordpress/interactivity') === ($payloadBlock['script_dependencies']['view.js'] ?? null), 'the view module declares the Interactivity API import so the generated asset manifest resolves it'); $assert(isset($payloadBlock['assets']['index.js'], $payloadBlock['assets']['style.css']) && str_contains((string) ($payloadBlock['view_js'] ?? ''), "store( 'blocks-engine/carousel'"), 'the companion payload carries editor, style, and frontend behavior assets'); $assert(!isset($payloadBlock['render'], $payloadBlock['renderer'], $payloadBlock['block_json']['render']), 'the carousel needs no executable PHP renderer'); From afd92ce9f1bf9966e37659970171ea8973407e3b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 1 Sep 2026 11:37:42 -0400 Subject: [PATCH 03/10] Recognize camel-cased carousel identities --- .../HtmlToBlocks/Classification/SourceElementClassifier.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/Classification/SourceElementClassifier.php b/php-transformer/src/HtmlToBlocks/Classification/SourceElementClassifier.php index e0d58f55..3d5c8d4d 100644 --- a/php-transformer/src/HtmlToBlocks/Classification/SourceElementClassifier.php +++ b/php-transformer/src/HtmlToBlocks/Classification/SourceElementClassifier.php @@ -482,14 +482,14 @@ public function hasCapturedMediaContent(DOMElement $element): bool public function hasCarouselIdentity(DOMElement $element): bool { - $identity = strtolower(implode(' ', array( + $identity = strtolower((string) preg_replace(array('/([a-z0-9])([A-Z])/', '/([A-Z]+)([A-Z][a-z])/'), array('$1 $2', '$1 $2'), implode(' ', array( $element->tagName, SourceDom::attr($element, 'id'), SourceDom::attr($element, 'class'), SourceDom::attr($element, 'role'), SourceDom::attr($element, 'data-hook'), SourceDom::attr($element, 'data-testid'), - ))); + )))); return 1 === preg_match('/(?:^|[^a-z0-9])(?:carousel|gallery|slider|slideshow)(?:[^a-z0-9]|$)/', $identity); } From 4a18a5146ef5a9853ebe2bee5fe88b562f94f451 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 1 Sep 2026 11:01:58 -0400 Subject: [PATCH 04/10] Initialize carousel state from its interactive root --- .../AuthoredCarouselBlockGenerator.php | 33 ++++++++++++------- .../tests/unit/authored-carousel-block.php | 2 +- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/php-transformer/src/HtmlToBlocks/Generators/AuthoredCarouselBlockGenerator.php b/php-transformer/src/HtmlToBlocks/Generators/AuthoredCarouselBlockGenerator.php index 1a5ed551..97b842e2 100644 --- a/php-transformer/src/HtmlToBlocks/Generators/AuthoredCarouselBlockGenerator.php +++ b/php-transformer/src/HtmlToBlocks/Generators/AuthoredCarouselBlockGenerator.php @@ -51,6 +51,8 @@ function rootProps( attributes ) { const slidesOf = ( ref ) => Array.from( ref.querySelectorAll( '.blocks-engine-authored-carousel__track > *' ) ); +const rootOf = ( ref ) => ref.closest( '.blocks-engine-authored-carousel' ); + const visibleCount = ( ref ) => { const viewport = ref.querySelector( '.blocks-engine-authored-carousel__viewport' ); const slides = slidesOf( ref ); @@ -61,17 +63,21 @@ function rootProps( attributes ) { return width > 0 ? Math.max( 1, Math.min( slides.length, Math.round( viewport.clientWidth / width ) ) ) : 1; }; -const maximumIndex = ( ref ) => Math.max( 0, slidesOf( ref ).length - visibleCount( ref ) ); +const maximumIndex = ( context ) => Math.max( 0, context.count - context.visible ); const show = ( requested ) => { const context = getContext(); const { ref } = getElement(); - const maximum = maximumIndex( ref ); + const root = rootOf( ref ); + if ( ! root ) { + return; + } + const maximum = maximumIndex( context ); context.index = context.wrap ? ( requested < 0 ? maximum : requested > maximum ? 0 : requested ) : Math.max( 0, Math.min( maximum, requested ) ); - const viewport = ref.querySelector( '.blocks-engine-authored-carousel__viewport' ); - const slide = slidesOf( ref )[ context.index ]; + const viewport = root.querySelector( '.blocks-engine-authored-carousel__viewport' ); + const slide = slidesOf( root )[ context.index ]; if ( viewport && slide ) { viewport.scrollTo( { left: slide.offsetLeft, @@ -84,19 +90,24 @@ function rootProps( attributes ) { state: { get atStart() { const context = getContext(); - const { ref } = getElement(); - return 0 === maximumIndex( ref ) || ( ! context.wrap && 0 === context.index ); + return 0 === maximumIndex( context ) || ( ! context.wrap && 0 === context.index ); }, get atEnd() { const context = getContext(); - const { ref } = getElement(); - const maximum = maximumIndex( ref ); + const maximum = maximumIndex( context ); return 0 === maximum || ( ! context.wrap && context.index === maximum ); }, get statusText() { + const context = getContext(); + return 'Slide ' + ( context.index + 1 ) + ' of ' + context.count; + }, + }, + callbacks: { + init() { const context = getContext(); const { ref } = getElement(); - return 'Slide ' + ( context.index + 1 ) + ' of ' + slidesOf( ref ).length; + context.count = slidesOf( ref ).length; + context.visible = visibleCount( ref ); }, }, actions: { @@ -152,13 +163,13 @@ public function shell(array $attributes): array $wrap = false === ($attributes['wrap'] ?? true) ? 'false' : 'true'; $context = htmlspecialchars( - (string) json_encode(array('index' => 0, 'wrap' => 'true' === $wrap), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), + (string) json_encode(array('index' => 0, 'wrap' => 'true' === $wrap, 'count' => 0, 'visible' => $items), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' ); return array( - 'opening' => '