Search by

cloakwp / block-parser

kaelan

Parse Gutenberg + ACF Blocks into structured objects.

Package info

github.com/cloak-labs/wp-block-parser

pkg:composer/cloakwp/block-parser

Statistics

Installs: 152

Dependents: 1

Suggesters: 0

Stars: 2

Open Issues: 0

0.0.16 2026-03-17 23:09 UTC

README

CloakWP Block Parser is a PHP library designed to parse and transform Gutenberg and ACF blocks into structured objects/JSON. This package is part of the CloakWP ecosystem, but can be used independently or as a dependency of your own plugins/themes/packages.

Features

  • Parse Gutenberg (core) blocks into objects/JSON
  • Parse Advanced Custom Fields (ACF) blocks into objects/JSON
  • Extensible architecture for custom block transformers
  • Filters for modifying parsed block data

Motivation

WordPress block content is stored as HTML strings in the database rather than as structured data (e.g. JSON). This is particularly a problem for decoupled/headless projects where you may wish to render things your own way; to do so, you need the blocks in JSON/structured object form -- turns out this is surprisingly difficult to achieve. CloakWP Block Parser simplifies this process by providing a clean and organized way to parse and transform blocks content into structured data.

Installation

You can install this package via Composer:

composer require cloakwp/block-parser

Requires PHP 8.4+ with the DOM extension. HTML extraction uses PHP's native Dom\HTMLDocument HTML5 parser and CSS selectors. WordPress's parse_blocks() continues to parse block delimiters and nesting.

Example Output

Structured data
[
  {
    "name": "core/paragraph",
    "type": "core",
    "attrs": {
      "content": "Contact us via phone <a href=\"tel:123-456-7890\">(123) 456-7890</a> or email <a href=\"mailto:info@example.com\">info@example.com</a>.",
      "dropCap": false
    }
  },
  {
    "name": "acf/hero",
    "type": "acf",
    "attrs": {
      "style": {
        "spacing": {
          "margin": {
            "bottom": "var:preset|spacing|60"
          }
        }
      },
      "className": "pb-8 md:pb-10",
      "align": "full",
      "backgroundColor": "bg-root-dim"
    },
    // ACF field data:
    "data": {
      "hero_style": "image_right",
      "image": {
        "medium": {
          "src": "http://localhost/app/uploads/sites/8/2024/08/example-300x200.jpeg",
          "width": 300,
          "height": 200
        },
        "large": {
          "src": "http://localhost/app/uploads/sites/8/2024/08/example-1024x683.jpeg",
          "width": 1024,
          "height": 683
        },
        "full": {
          "src": "http://localhost/app/uploads/sites/8/2024/08/example.jpeg",
          "width": 1620,
          "height": 1080
        },
        "alt": "example alt description",
        "caption": "example caption"
      },
      "eyebrow": "WordPress Experts",
      "h1": "Build your dream website.",
      "subtitle": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
      "cta_buttons": false,
      "show_social_proof": false
    }
  }
]

Usage

Basic Usage

use CloakWP\BlockParser\BlockParser;

$postId = 123;
$blockParser = new BlockParser();
$blockData = $blockParser->parseBlocksFromPost($postId);

HTML adapters

The default NativeHtmlAdapter parses UTF-8 HTML once per block, only when an attribute needs HTML. Nested query sources reuse node contexts. Stored values, defaults, and post meta do not create a DOM.

Inject an implementation of HtmlAdapterInterface to select an engine for one parser, including its nested blocks and synced patterns:

use CloakWP\BlockParser\BlockParser;
use CloakWP\BlockParser\Html\NativeHtmlAdapter;
use CloakWP\BlockParser\Html\PQueryHtmlAdapter;

$parser = new BlockParser(new NativeHtmlAdapter());
$legacyParser = new BlockParser(new PQueryHtmlAdapter());

pQuery is optional in production. Install tburry/pquery:^1.1 explicitly to use PQueryHtmlAdapter; it is already included in development dependencies for tests and comparisons. The same adapter can be passed to new AttributeParser($adapter) for standalone extraction.

For parser instances created by integrations, choose the default through a filter:

add_filter('cloakwp/block_parser/html_adapter', fn() => new PQueryHtmlAdapter());

Explicit constructor injection takes precedence over the filter. The filter also applies to standalone AttributeParser instances and must return an HtmlAdapterInterface. BlockParser::getHtmlAdapter() exposes the chosen adapter to custom transformers.

An adapter implements parse(string): HtmlNodeInterface. Nodes provide select(), selectAll(), attribute(), html(), text(), and tagName(). Selections return elements in document order and include the current element when it matches, so fields inside query sources can read the query row itself. Omitting a selector reads the first element. Missing matches/attributes return null, while empty values are omitted by AttributeParser.

Both adapters support attribute, html, rich-text, text, tag, and nested query sources. Table cells retain their context, including th tags and rich text without selectors. Nested meta sources retain the post ID, and omitted query rows produce dense JSON arrays.

Native HTML5 recovery and serialization can differ from pQuery on malformed markup, entity escaping, whitespace, and advanced selectors. Native markup preserves entity escaping and text() preserves text-node whitespace; pQuery retains its legacy serialization and whitespace behavior. Review payload changes when switching an existing installation. Invalid native CSS selectors throw DOMException rather than silently producing missing attributes.

The experimental TagProcessorHtmlAdapter uses WordPress's WP_HTML_Tag_Processor for plain tag selectors, comma-separated tag lists and *. A flat span/nesting index lets nested queries reuse the original fragment. Other CSS selectors use a lazy, shared native DOM. Tables, raw-text elements, foreign content, unsupported tags and markup requiring tree repair also fall back to native parsing. This is a conservative optimization subset, not a second HTML5 tree builder. It uses a subclass's protected bookmark spans for offsets; compatibility tests should accompany WordPress upgrades.

use CloakWP\BlockParser\Html\TagProcessorHtmlAdapter;

$experimentalParser = new BlockParser(new TagProcessorHtmlAdapter());

By default, html() retains native serialization; this creates a DOM when rich text is first requested. For experiments only, new TagProcessorHtmlAdapter( preserveSourceHtml: true) slices original inner markup on its streaming subset. That avoids DOM construction but preserves original tag case, entity spelling, and attribute quoting instead of canonicalizing them. Native fallback nodes still use native serialization. Review output before enabling this option.

The native adapter remains recommended. Initial PHP 8.4/WordPress 6.7 benchmarks found no extraction speedup from either tag-processor mode, even though a bare single-attribute lookup was modestly faster. See the measured results and the repeatable benchmark scripts for the workload and timing details.

Extending

Custom Transformers

The BlockParser uses the built-in core function, parse_blocks(), to initially parse the blocks, but unfortunately this function doesn't do the full job. So, we extend the basic built-in parsing with block "transformers".

By default, the BlockParser uses the following transformers:

  • CoreBlockTransformer (for Gutenberg core blocks)
  • ACFBlockTransformer (for ACF blocks)

You can extend the BlockParser by registering your own custom block transformers for certain block types, or to override the default transformers:

use CloakWP\BlockParser\Transformers\AbstractBlockTransformer;

// Extend the abstract base to inherit getType() and the parser constructor.
// The static $type property selects the block type this transformer handles:
class MyCustomACFBlockTransformer extends AbstractBlockTransformer
{
  protected static string $type = 'acf'; // this will override the default ACFBlockTransformer

  public function transform(WP_Block $block, int|null $postId = null): array
  {
    // your custom data transformation code here -- whatever you return here will be the final block data
  }
}

// now register the transformer with your BlockParser instance:
$blockParser = new BlockParser();
$blockParser->registerTransformer(MyCustomACFBlockTransformer::class);

If in the above example you want to add a transformer for some custom block type, you just specify a custom value for the static $type property, and then extend the BlockParser class and override the determineBlockType() method to add your logic for determining when a block is of your custom type; for example:

class MyCustomBlockParser extends BlockParser
{
  protected function determineBlockType(WP_Block $block): string
  {
    if ($block->name === 'my-plugin/custom-block') {
      return 'custom';
    }

    return parent::determineBlockType($block);
  }
}

class MyCustomBlockTransformer extends AbstractBlockTransformer
{
  protected static string $type = 'custom';

  public function transform(WP_Block $block, int|null $postId = null): array
  {
    // ..
  }
}

$postId = 123;
$blockParser = new MyCustomBlockParser();
$blockParser->registerTransformer(MyCustomBlockTransformer::class);

// Blocks named 'my-plugin/custom-block' now use MyCustomBlockTransformer.
$blockData = $blockParser->parseBlocksFromPost($postId);

Block-specific behavior is a separate class dispatched by CoreBlockTransformer. CoreImageTransformer and CoreButtonTransformer are registered by default. Extend one of those classes to keep its behavior and add your own, or extend AbstractBlockSpecificTransformer for a block that does not have one yet. The static $blockName property selects the block. Registering another class with the same name replaces the earlier one. Each class is constructed with no arguments.

Add the filter before the parser is constructed. CloakWP builds its parser during init, so a theme or plugin can register the filter as its file loads:

use CloakWP\BlockParser\Transformers\AbstractBlockSpecificTransformer;
use CloakWP\BlockParser\Transformers\CoreButtonTransformer;

class MyButtonTransformer extends CoreButtonTransformer
{
  public function transform(array $block, WP_Block $source, int|null $postId = null): array
  {
    $block = parent::transform($block, $source, $postId);
    $block['attrs']['tracking'] = 'button';

    return $block;
  }
}

class QuoteTransformer extends AbstractBlockSpecificTransformer
{
  protected static string $blockName = 'core/quote';

  public function transform(array $block, WP_Block $source, int|null $postId = null): array
  {
    $block['attrs']['citation'] ??= '';

    return $block;
  }
}

add_filter('cloakwp/block_parser/block_transformers', function (array $classes) {
  $classes[] = MyButtonTransformer::class;
  $classes[] = QuoteTransformer::class;

  return $classes;
});

When you own the parser, register a class on that instance. This also clears its cached post trees:

$blockParser->registerBlockTransformer(QuoteTransformer::class);

These classes run for blocks handled by CoreBlockTransformer, including core blocks and other blocks that fall through to it. Replacing the core transformer with a subclass keeps the registry. cloakwp/block and cloakwp/block/name=<block> remain available for adjustments after transformation.

Filter Hooks

Besides creating custom transformers, you can also modify parsed block data using filters. These filters are applied after the block has been transformed by the appropriate transformer, but before the block is returned:

add_filter('cloakwp/block', function(array $parsedBlock, WP_Block $wpBlock) {
  // modify $parsedBlock here
  return $parsedBlock;
}, 10, 2);

The cloakwp/block filter accepts two modifiers, name and type, for more granular targeting:

add_filter('cloakwp/block/name=core/paragraph', function(array $parsedBlock, WP_Block $wpBlock) {
  // modify $parsedBlock here
  return $parsedBlock;
}, 10, 2);

add_filter('cloakwp/block/type=acf', function(array $parsedBlock, WP_Block $wpBlock) {
  // modify $parsedBlock here
  return $parsedBlock;
}, 10, 2);

After every ACF field on a block has been formatted — and before cloakwp/block — you can transform the complete parsed block together with its field definitions:

add_filter('cloakwp/block/data', function (array $parsedBlock, array $fieldDefinitions, WP_Block $wpBlock, ?int $postId) {
  // $fieldDefinitions is the ACF field-object tree for the block (nested `sub_fields` included),
  // even for fields Gutenberg omitted because they were empty.
  // Value-aware InnerBlocks loading leaves unused fields' `layouts` empty (see below).
  return $parsedBlock;
}, 10, 4);

Use this hook to inject derived data (for example resolving a Query group into data.items) without replacing a block-level cloakwp/block callback. cloakwp/block runs after this filter.

You can also filter ACF field values within ACF blocks using the cloakwp/block/field filter:

add_filter('cloakwp/block/field', function(mixed $fieldValue, array $fieldObject) {
  // modify $fieldValue here
  return $fieldValue;
}, 10, 2);

The cloakwp/block/field filter accepts three modifiers, name (i.e. ACF field name), type (i.e. ACF field type), and blockName (i.e. ACF block name), for more granular targeting:

add_filter('cloakwp/block/field/name=my_acf_field', function(mixed $fieldValue, array $fieldObject) {
  // modify $fieldValue here
  return $fieldValue;
}, 10, 2);

add_filter('cloakwp/block/field/type=image', function(mixed $fieldValue, array $fieldObject) {
  // modify $fieldValue here
  return $fieldValue;
}, 10, 2);

add_filter('cloakwp/block/field/blockName=acf/hero-section', function(mixed $fieldValue, array $fieldObject) {
  // modify $fieldValue here
  return $fieldValue;
}, 10, 2);

Tests

From this package's directory, using PHP 8.4+ and Composer:

composer install
composer test
composer test:pquery
composer test:tag-processor

Run a focused test or check that tests are independent of execution order:

composer test -- --filter BlockParserTest
composer test -- --order-by=random --random-order-seed=42

The suite covers serialized Gutenberg content, nested blocks, synced patterns (including deleted references), custom transformers, WordPress block bindings, hook modifiers and filter order, HTML/query/meta attribute sources, image dimensions, block-specific transformers, ACF formatting and nested groups, and profiling response headers.

WordPress core is a development-only Composer dependency. The bootstrap loads its real parser, block objects, hooks, REST schema validation, HTML processing, and shortcodes without starting a site. In-memory doubles provide posts, meta, attachments, asset queues, and ACF APIs. ACF tests run in separate processes so their functions cannot affect tests for installations without ACF. No database, Docker, or ACF Pro license is needed. These tests verify this package's handling of ACF results; they do not test ACF Pro's own formatting or a full site's plugins.

tests/Fixtures/ contains saved Gutenberg markup, and tests/Support/ contains the isolated test environment. To check against another local WordPress version, set WP_TESTS_CORE_PATH to its core directory (the directory containing wp-includes) before running composer test.

GitHub Actions runs all three adapters with PHPUnit 11 on PHP 8.4 and 8.5, plus PHPUnit 12 on PHP 8.4. Warnings, risky tests, empty suites, and deprecations originating in this package fail the run. Dependency deprecations are excluded from that check.

With Xdebug or PCOV enabled, generate coverage for src/:

composer test -- --coverage-text --coverage-html coverage/html

Performance comparisons

Structured parsing avoids preliminary HTML rendering unless a block has WordPress bindings. HTML requested through cloakwp/block/include_rendered is still rendered by its transformer. If a custom render callback computes attributes that your integration needs, opt that block into the preliminary render:

add_filter('cloakwp/block/render_for_attributes', function ($render, $block, $postId) {
  return $render || $block->name === 'my-plugin/computed-attributes';
}, 10, 3);

ACF's local registry repeatedly scans every registered field to find each parent's children. BlockParser indexes those parent lookups while transforming a block, including rendering, nested blocks, and block filters. ACF still loads complete definitions, expands clones and flexible layouts, and runs its normal field and value filters. The original registry is restored afterward, including on exceptions. The index is reused within the request and refreshed when registry data changes. Ordinary parseBlocksFromPost() does not cache parsed post data or REST responses. Consumers that share the same parser instance can opt into parseBlocksFromPostCached($post) to reuse a transformed post tree. This is useful when both a REST field and an SEO integration need the same blocks, regardless of which consumer runs first. The cache separates blog/post IDs, checks the post's content and WordPress's post/term change markers, and is cleared when transformers are replaced. It lasts only for that parser instance; no persistent output cache is written. Call clearParsedPostCache() after changing formatting hooks or other request-local dependencies. Cyclic synced-pattern references terminate safely.

This optimization uses ACF's internal ACF_Data registry. Custom store subclasses and unsupported query shapes use ACF's original behavior. To disable it for an integration or compare the same routes with the original lookups:

add_filter('cloakwp/block_parser/index_acf_fields', '__return_false');

Compatible CloakWP ACF InnerBlocks fields also enable value-aware loading. Before loading a block's field group, the parser checks its saved ACF field references. Empty or omitted InnerBlocks fields keep empty layouts during that transformation. Populated fields export only the child layouts named in their saved rows, combining all occurrences of a shared field key across repeater rows. Defaults and unknown value shapes retain complete layouts. Ordinary fields, including omitted Query defaults, still use ACF's native discovery and formatting.

Each transformation uses a temporary copy of ACF's internal ACF_Data field cache, restored on completion or exception. This keeps incomplete definitions out of the normal cache and supports nested parsing and multisite switches. Normal ACF reads and editor controls still load full layouts. Custom field stores retain normal loading, and installations without a compatible InnerBlocks integration do not enable this path.

The cloakwp/block/data hook sees only used layouts for InnerBlocks fields. If an integration requires that metadata or injects rows through runtime value filters, use InnerBlocks::lazyLayouts(false) on those fields or disable the path:

add_filter('cloakwp/block_parser/value_aware_fields', '__return_false');

Before transforming an ACF block, the parser fires cloakwp/block_parser/load_block_fields (string $name, WP_Block $block, ?int $postId). An integration can import that block's schema here while retaining its complete metadata and callback catalog. Native WordPress rendering must have its own schema loader, for example on render_block_data; it does not pass through this parser hook.

The real-ACF integration check registers request-only fixtures without saving content. It covers empty groups, populated fields after empty ones, mixed repeater rows, omitted Query defaults, cache restoration, and editor controls:

docker exec pillar-wordpress wp --allow-root eval-file \
  /var/www/local-packages/cloakwp-block-parser/tests/Integration/ValueAwareFieldsWordPress.php \
  --use-include --url=https://wp.localhost/hyland/

See benchmarks/README.md for fixed extraction workloads and pinned before/after comparisons with @cloakwp/benchmark. They supplement real content routes without changing site content or runtime settings.