Problem
On a decoupled site the entity body is not searchable. Search API's core Rendered item processor renders the entity through the standard render pipeline, but on a decoupled setup the body is assembled by the Custom Elements generator instead. The rendered-item output therefore misses the actual content: body text, paragraph fields and Canvas component text are all absent from the index, while being plainly visible on the page.
The net effect is that body-only terms return zero hits. Titles and teaser fields match; everything inside the body does not.
There is currently no supported way to get the text out of a CustomElement tree, so any site hitting this has to write its own traversal. Doing that correctly is not obvious: text lives in slots and in props, props also carry URLs, colours and dimensions that must not be indexed, and nested component lists are stored as attribute values rather than slots.
Proposed solution
Add a reusable extractor to the base module, plus an optional Search API processor that uses it.
1. CustomElementSearchTextExtractor
Service custom_elements.search_text_extractor. It walks the element structurally through its accessors, getAttributes() for props and getSortedSlotsByName() for slots, rather than through a flattened array. It handles the content shapes the decoupled frontend renders (cf. nuxtjs-drupal-ce, renderCustomElementsToVNodes):
- a string is treated as drupal-markup and kept as raw HTML, so a downstream
html_filtercan weight tags before stripping them; - an array is a list of content items (a
string[]or mixed) and is recursed item by item; - a nested CustomElement, or an element-shaped array (legacy
{element, ...props}or explicit{element, props, slots}), is recursed as its own element.
Props are special-cased. A string prop is indexed unless its key is technical (URLs, paths, colours, dimensions, identifiers, structural tokens), and a prop whose value is itself nested content is recursed. That last point matters in practice: generators store rendered field content such as paragraph or component lists inside attribute values, not slots, so a slot-only walk silently indexes nothing but the title.
Any element or subtree flagged no-search-index is pruned during traversal.
2. custom_elements_fulltext Search API processor
A thin caller of the extractor, exposing a ce_fulltext property that can replace rendered_item on a decoupled site.
There is deliberately no search_api dependency in custom_elements.info.yml. A Search API processor plugin is only discovered and instantiated by Search API's own plugin manager, which runs only when that module is enabled, so the class stays dormant and is never autoloaded on sites without Search API. Shipping it in the base module therefore adds no dependency and needs no submodule. The processor's kernel test declares search_api in its own $modules list, keeping the dependency test-only.
Why in custom_elements rather than in a consuming project
The traversal only depends on the CustomElement object model, and the pitfalls above (props versus slots, technical keys, nested attribute content) are properties of that model rather than of any one site. Every decoupled site that wants search has to solve exactly this, and getting it wrong fails quietly, because the index looks populated when titles match.
Scope
Because the extractor works from the element tree, it covers anything the generator can render, node bodies and Canvas pages alike, without per-entity-type handling.
Tests
Kernel coverage for both classes: 7 tests, 43 assertions, green against 3.4.1.
- string props kept, technical attributes dropped
- markup and plain-HTML slots indexed
- nested attribute content (the component-list shape) indexed
- the paragraphs-prop shape indexed
no-search-indexsubtrees prunedrenderless-containertreated as transparent- the processor delegating to the extractor
Patch
custom_elements-search-text-extractor.patch is attached, against 3.4.1. It has been verified to apply cleanly to a pristine 3.4.1 tree, and the two kernel tests pass on the result.
Files:
src/CustomElementSearchTextExtractor.php(new)src/Plugin/search_api/processor/CustomElementsFulltext.php(new)tests/src/Kernel/CustomElementSearchTextExtractorTest.php(new)tests/src/Kernel/CustomElementsFulltextTest.php(new)custom_elements.services.yml(service registration)
This has been running in production use on a decoupled Drupal distribution, with the body-indexing behaviour additionally covered by end-to-end tests there.
Drafted with the assistance of AI (Claude Code / Opus 4.8).
Issue fork custom_elements-3612219
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
jjchinquistComment #4
jjchinquistMerge request !187 is now open against
3.xand CI is green.drupal/search_apiis declared inrequire-dev. The module still has nosearch_apidependency incustom_elements.info.yml— the processor plugin is only ever instantiated by search_api's plugin manager and stays dormant otherwise — but CI needs the module present to autoload, analyse and test it.RenderedItemProperty, exposing render roles and per-datasource/bundle view mode settings, so the render used for indexing is configurable per bundle.CI on the MR:
phpcs,phpunit,composer-lint,stylelintandeslintall pass.phpstanreports 2 errors, both pre-existing on3.x(custom_elements_ui.modulehook_entity_operation cacheability, and an entity-storage property inCustomElementsRenderMarkupTest) — neither is in a file this MR touches, and the job isallow_failure: trueand already red on3.x.Comment #5
jjchinquist@Wolfgang, please reivew and because it has been checked by us both now as well as being on one of our production systems, I assume it can be considered RTBC.
Comment #7
fagowhile this is generally solid, it seems the "roles" setting is not applied. needs work.
Comment #8
fagoComment #10
fagoI've added the missing support for roles-switching and improved test-coverage. Re-tested all works as it should still also, seems all good - thus merged!