Add Qdrant embedding support to v3 WordPress components for RAG chatbot. Implements component-level content chunking for searchable, structured embeddings...
You are helping add Qdrant embedding support to WordPress v3 components. This enables component content to be indexed and searched via a RAG-based chatbot powered by Claude's API.
The embedding system:
ComponentEmbeddingSkipAwareInterfacewp vendi embedding:generate runsVENDI_RENDER_CONTEXT to RenderingContextEnum::EMBEDDINGvendi_load_component_v3()getEmbedding() method extracts structured dataComponentEmbedding DTO formats data into JSON chunks for QdrantEach component produces a JSON object like this:
{
"content": "Heading: Ask a Researcher\nBody: Are you a CRNA with research questions?\nLinks: Contact us",
"metadata": {
"type": "page",
"url": "https://example.com/page/",
"created": "2022-11-29T21:01:08+00:00",
"updated": "2024-03-07T09:07:06+00:00",
"links": [
{
"text": "Contact us",
"url": "https://example.com/contact/"
}
],
"component_type": "content_callout_full_width"
},
"id": "660-3"
}
ComponentEmbeddingAwareInterfacegetEmbedding() methodComponentEmbeddingSkipAwareInterface (marker interface)getEmbedding() method neededWhen to use: Component has just heading and/or body copy, no repeater fields
IMPORTANT: Inspect the actual template file to determine which interfaces to implement:
PrimaryHeadingInterface - Use when template displays a component-level heading (outside loops)
<h2><?php esc_html_e(get_sub_field('headline')); ?></h2> at the top levelPrimaryCopyInterface - Use when template displays component-level body/intro copy (outside loops)
<?php echo wp_kses_post(get_sub_field('intro_copy')); ?> before any repeatersThe interfaces should map to what actually exists in the template structure.
use Vendi\Theme\ComponentInterfaces\ComponentEmbeddingAwareInterface;
use Vendi\Theme\ComponentInterfaces\PrimaryHeadingInterface; // If template has top-level heading
use Vendi\Theme\ComponentInterfaces\PrimaryCopyInterface; // If template has top-level copy
use Vendi\Theme\DTO\Embedding\ComponentEmbedding;
use Vendi\Theme\DTO\Embedding\ComponentEmbeddingInterface;
class simple_component extends BaseComponent implements
ComponentEmbeddingAwareInterface,
PrimaryHeadingInterface, // Only if template has top-level heading
PrimaryCopyInterface // Only if template has top-level copy
{
public function getEmbedding(): ?ComponentEmbeddingInterface
{
return ComponentEmbedding::fromComponent($this);
}
public function getPrimaryHeadingText(): ?string
{
// Return the field that corresponds to the top-level heading in template
return get_sub_field('headline');
}
public function getPrimaryCopy(): ?string
{
// Return the field that corresponds to the top-level copy in template
return get_sub_field('copy');
}
}
Heading: [from getPrimaryHeadingText() if interface implemented]
Body: [from getPrimaryCopy() if interface implemented]
Key Points:
fromComponent() auto-extracts heading and body via interfacesWhen to use: Ads, navigation, forms, decorative/visual-only elements
use Vendi\Theme\ComponentInterfaces\ComponentEmbeddingSkipAwareInterface;
class ad_component extends VendiComponent implements ComponentEmbeddingSkipAwareInterface
{
// No getEmbedding() method needed
// Component completely ignored during embedding generation
}
Key Points:
When to use: Component has repeater or flexible content fields where each item should be a separate section
use Vendi\Theme\ComponentInterfaces\ComponentEmbeddingAwareInterface;
use Vendi\Theme\ComponentInterfaces\PrimaryHeadingInterface;
use Vendi\Theme\ComponentInterfaces\PrimaryCopyInterface;
use Vendi\Theme\DTO\Embedding\ComponentEmbedding;
use Vendi\Theme\DTO\Embedding\ComponentEmbeddingInterface;
public function getEmbedding(): ?ComponentEmbeddingInterface
{
// Start with base embedding (auto-extracts heading/body from interfaces)
$ret = ComponentEmbedding::fromComponent($this);
// Loop through repeater field
while (have_rows('items')) {
the_row();
$layout = get_row_layout();
// CRITICAL: Filter to relevant layouts only
if (!in_array($layout, ['content_item', 'text_block'], true)) {
continue;
}
$heading = get_sub_field('heading');
$copy = get_sub_field('copy');
// CRITICAL: Always clean HTML from user content
$cleanCopy = ComponentEmbedding::stripAllHtmlFromText($copy);
// Add section with optional custom label
$ret->addSection(
$heading . PHP_EOL . $cleanCopy,
'Section' // Optional: 'FAQ Item', 'Testimonial', etc.
);
}
return $ret;
}
Heading: [component main heading]
Body: [component intro copy]
Section 1: Item 1 Heading
[item 1 copy]
Section 2: Item 2 Heading
[item 2 copy]
Key Points:
stripAllHtmlFromText() for all HTML contentaddSection() creates a separate sub-chunkWhen to use: Component has call-to-action buttons or links that should be tracked in metadata
public function getEmbedding(): ?ComponentEmbeddingInterface
{
$ret = ComponentEmbedding::fromComponent($this);
while (have_rows('cards')) {
the_row();
$heading = get_sub_field('heading');
$copy = get_sub_field('copy');
$link = get_sub_field('cta');
// Build structured content with labels
$contentParts = [];
if ($heading) {
$contentParts[] = 'Heading: ' . $heading;
}
if ($copy) {
$contentParts[] = 'Body: ' . $copy;
}
if ($link && is_array($link)) {
$contentParts[] = 'Link: ' . $link['title'];
}
// Only add section if there's content
if ($content = implode(PHP_EOL, array_filter($contentParts))) {
$ret->addSection($content);
}
// CRITICAL: Track link separately in metadata
if ($link && is_array($link)) {
$ret->addLink(
linkText: $link['title'] ?? '',
linkUrl: $link['url'] ?? ''
);
}
}
return $ret;
}
{
"content": "Heading: Component Title\nBody: Intro text\nLinks: Card 1 CTA, Card 2 CTA\nSection 1:\nHeading: Card 1\nBody: Card 1 copy\nLink: Card 1 CTA",
"metadata": {
"links": [
{
"text": "Card 1 CTA",
"url": "/page1/"
},
{
"text": "Card 2 CTA",
"url": "/page2/"
}
],
"component_type": "card_navigation"
}
}
Key Points:
When to use: Component has HTML content (bios, articles, descriptions) with embedded <a> tags that should be tracked
public function getEmbedding(): ?ComponentEmbeddingInterface
{
$ret = ComponentEmbedding::fromComponent($this);
while (have_rows('items')) {
the_row();
$name = get_sub_field('name');
$bio = get_sub_field('bio'); // Contains HTML with links
// CRITICAL: Extract links BEFORE stripping HTML
// Use name as prefix for context
ComponentEmbedding::extractAndAddLinksFromHtml($ret, $bio, $name);
// Now strip HTML for text content
$cleanBio = ComponentEmbedding::stripAllHtmlFromText($bio);
$ret->addSection(
'Name: ' . $name . PHP_EOL . 'Bio: ' . $cleanBio,
'Person'
);
}
return $ret;
}
If bio contains: <p>Follow me on <a href="https://twitter.com/jdoe">Twitter</a></p>
{
"content": "Person 1: Name: John Doe\nBio: Follow me on Twitter",
"metadata": {
"links": [
{"text": "John Doe Twitter", "url": "https://twitter.com/jdoe"}
]
}
}
Key Points:
extractAndAddLinksFromHtml() BEFORE stripAllHtmlFromText()When to use: Component displays content from related WP_Post objects (testimonials, people, etc.)
public function getEmbedding(): ?ComponentEmbeddingInterface
{
$ret = ComponentEmbedding::fromComponent($this);
foreach ($this->getRelatedPosts() as $post) {
// CRITICAL: Validate post object before accessing fields
if (!$post instanceof WP_Post) {
continue;
}
$name = get_field('name', $post->ID);
$bio = get_field('bio', $post->ID);
// Clean HTML and add with custom section label
$ret->addSection(
$name . PHP_EOL . ComponentEmbedding::stripAllHtmlFromText($bio),
'Person' // Custom label: 'Testimonial', 'Team Member', etc.
);
}
return $ret;
}
Key Points:
instanceof WP_Post before accessing post fieldsget_field('field_name', $post->ID)CRITICAL: Every embeddable component template must include this boilerplate at the top.
<?php
use Vendi\Theme\Component\{component_name};
use Vendi\Theme\ComponentUtility;
use Vendi\Theme\Enums\RenderingContextEnum;
/** @var {component_name} $component */
$component = ComponentUtility::get_new_component_instance({component_name}::class);
// CRITICAL: Early return for embedding context
if (defined('VENDI_RENDER_CONTEXT') && VENDI_RENDER_CONTEXT === RenderingContextEnum::EMBEDDING->value) {
return $component;
}
if (!$component->renderComponentWrapperStart()) {
return;
}
?>
<!-- HTML template here -->
<?php
$component->renderComponentWrapperEnd();
Without the embedding context check:
getEmbedding() method will never be calledThis boilerplate is required even for skippable components (for consistency).
fromComponent($this)Purpose: Create base embedding with auto-extraction
Auto-extracts:
PrimaryHeadingInterface implemented - based on template inspection)PrimaryCopyInterface implemented - based on template inspection)Usage: Always first line of getEmbedding()
public function getEmbedding(): ?ComponentEmbeddingInterface
{
$ret = ComponentEmbedding::fromComponent($this);
// ... add sections, links, etc.
return $ret;
}
Note: The heading and body auto-extraction only works if you've implemented the corresponding interfaces based on what actually exists in the template (see Pattern 1 for details).
addSection(string $text, string $sectionLabel = 'Section')Adds a labeled section to the embedding. Sections are auto-numbered (Section 1, Section 2, etc.).
Best Practice: Use descriptive labels
// Good: Descriptive
$ret->addSection($content, 'Testimonial');
$ret->addSection($content, 'FAQ Item');
$ret->addSection($content, 'Team Member');
// Acceptable: Default auto-numbering
$ret->addSection($content); // "Section 1", "Section 2", etc.
addLink(string $linkText, string $linkUrl)Adds a link to metadata. Links stored separately from content text for advanced RAG features.
if ($link && is_array($link)) {
$ret->addLink(
linkText: $link['title'] ?? '',
linkUrl: $link['url'] ?? ''
);
}
extractAndAddLinksFromHtml(ComponentEmbedding $embedding, ?string $html, string $linkPrefix = '')Purpose: Extracts all <a> tags from HTML content and adds them to the embedding's link metadata.
When to use: When content contains HTML with embedded links that should be tracked separately (e.g., biographical text with social media links, articles with reference links).
Parameters:
$embedding - The ComponentEmbedding instance to add links to$html - HTML content to parse for links$linkPrefix - Optional prefix to add context to link text (e.g., person name)Features:
Usage:
// Basic usage - extract links from HTML
ComponentEmbedding::extractAndAddLinksFromHtml($ret, $htmlContent);
// With prefix for context (recommended when looping through items)
foreach ($persons as $person) {
$name = $person->name;
$bio = $person->bio; // Contains <a href="...">Twitter</a>, <a href="...">LinkedIn</a>
// Prefix links with person name: "John Doe Twitter", "John Doe LinkedIn"
ComponentEmbedding::extractAndAddLinksFromHtml($ret, $bio, $name);
// Clean HTML after extracting links
$cleanBio = ComponentEmbedding::stripAllHtmlFromText($bio);
$ret->addSection("Name: $name\nBio: $cleanBio", 'Person');
}
Why use linkPrefix: Without prefix, 20 people with Twitter links produces 20 identical "Twitter" entries. With prefix, you get "Chris Haas Twitter", "Jane Smith Twitter", etc., providing essential context.
Important: Call extractAndAddLinksFromHtml() BEFORE stripAllHtmlFromText() to preserve the links before HTML is removed.
stripAllHtmlFromText(?string $text, bool $preserveLists = false)CRITICAL: Always use this for user-entered HTML content
Features:
<script>, <style>, <form> tags and HTML comments& โ &)Usage:
// DO THIS:
$cleanCopy = ComponentEmbedding::stripAllHtmlFromText($copy);
$ret->addSection($cleanCopy);
// NOT THIS:
$ret->addSection($copy); // May contain <div>, <p>, <br> tags
VERY IMPORTANT: Write extraction code directly in getEmbedding(). Do NOT load sub-components.
Strongly Preferred:
public function getEmbedding(): ?ComponentEmbeddingInterface
{
$ret = ComponentEmbedding::fromComponent($this);
// Write code directly - NO sub-component loading
while (have_rows('items')) {
the_row();
$ret->addSection(get_sub_field('copy'));
}
return $ret;
}
Avoid:
// DON'T load sub-components during embedding
vendi_load_component_v3(['parent', 'child']);
Why: The system hasn't found a good pattern for sub-component loading in embeddings yet. Keep it simple and direct.
// CORRECT:
$cleanCopy = ComponentEmbedding::stripAllHtmlFromText($copy);
$ret->addSection($cleanCopy);
// WRONG:
$ret->addSection($copy); // HTML tags leak into embedding
Makes content more parseable by the RAG system:
$contentParts = [];
if ($heading) {
$contentParts[] = 'Heading: ' . $heading;
}
if ($subheading) {
$contentParts[] = 'Subheading: ' . $subheading;
}
if ($copy) {
$contentParts[] = 'Body: ' . ComponentEmbedding::stripAllHtmlFromText($copy);
}
if ($link) {
$contentParts[] = 'Link: ' . $link['title'];
}
$ret->addSection(implode(PHP_EOL, $contentParts));
.php) to understand its structurePrimaryHeadingInterfacePrimaryCopyInterfaceuse Vendi\Theme\ComponentInterfaces\ComponentEmbeddingAwareInterface;
use Vendi\Theme\DTO\Embedding\ComponentEmbedding;
use Vendi\Theme\DTO\Embedding\ComponentEmbeddingInterface;
use Vendi\Theme\ComponentInterfaces\PrimaryHeadingInterface;
use Vendi\Theme\ComponentInterfaces\PrimaryCopyInterface;
ComponentEmbeddingAwareInterface in class declarationPrimaryHeadingInterfacePrimaryCopyInterfacegetEmbedding(): ?ComponentEmbeddingInterface methodPrimaryHeadingInterface: Add getPrimaryHeadingText(): ?string returning the appropriate fieldPrimaryCopyInterface: Add getPrimaryCopy(): ?string returning the appropriate fielduse Vendi\Theme\Enums\RenderingContextEnum;
if (defined('VENDI_RENDER_CONTEXT') && VENDI_RENDER_CONTEXT === RenderingContextEnum::EMBEDDING->value) {
return $component;
}
$ret = ComponentEmbedding::fromComponent($this);in_array() check)stripAllHtmlFromText() for all HTML contentaddSection() for each logical chunkaddLink() if component has CTAsinstanceof before accessing fields$retuse Vendi\Theme\ComponentInterfaces\ComponentEmbeddingSkipAwareInterface;
ComponentEmbeddingSkipAwareInterface in class declarationComponentEmbeddingAwareInterfacegetEmbedding() method neededAfter implementation, test with the CLI command:
wp vendi embedding:generate
This command:
VENDI_RENDER_CONTEXT to EMBEDDINGgetEmbedding() on embeddable componentsCheck the JSON output for:
<div>, <p>, <br> tags){
"content": "Heading: Research Topics\nSection 1: AANA's Current Priorities\nWhat are healthcare executives' perceptions...",
"metadata": {
"type": "page",
"url": "https://example.com/page/",
"created": "2022-11-29T21:01:08+00:00",
"updated": "2024-03-07T09:07:06+00:00",
"component_type": "accordion"
},
"id": "660-2"
}
stripAllHtmlFromText() on user contentgetEmbedding()instanceof WP_Post before accessing post fieldsaddSection()return $component; in embedding contextextractAndAddLinksFromHtml() BEFORE stripAllHtmlFromText()Examine these components for real-world patterns:
All located in: vendi-theme-parts/components/[component_name]/[component_name].class.php
Guide the user through implementing embedding support for a v3 component:
.php template to understand structurewp vendi embedding:generate and verify outputRemember:
PrimaryHeadingInterface only if template has top-level heading (outside loops)PrimaryCopyInterface only if template has top-level copy (outside loops)git add and git commit - you should NOT run these