Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
1518afb
Add Elementor V4 atomic editor content extraction
enricobattocchi Jun 3, 2026
f8e239b
fix(elementor-v4): read image src and alt from htmlCache
vraja-pro Jun 10, 2026
f99325a
fix(elementor-v4): extract classic text-editor widget content for lin…
vraja-pro Jun 10, 2026
b03c076
test(elementor-v4): cover internal link extraction from text-editor w…
vraja-pro Jun 10, 2026
76a1e0d
fix(elementor-v4): read image src/alt from preview DOM for newly adde…
vraja-pro Jun 10, 2026
68892fd
refactor(elementor-v4): split initialize.js into focused modules
vraja-pro Jun 10, 2026
ba8c3df
refactor: change import
vraja-pro Jun 10, 2026
6be617a
fix(elementor-v4): read live container model and listen to editor cha…
vraja-pro Jun 11, 2026
6f5d00e
test(elementor-v4): split content-walker tests into focused files
vraja-pro Jun 11, 2026
82e805a
test(elementor-v4): add unit tests for document-tree, editor-data, ma…
vraja-pro Jun 11, 2026
dc89668
fix(elementor-v4): fix linting
vraja-pro Jun 11, 2026
3b92709
fix: adds elementor dependecies elementor-v4
vraja-pro Jun 11, 2026
911cef3
fix: removes elementor v4 stylesheet registration
vraja-pro Jun 11, 2026
2cf8919
fix: php cs
vraja-pro Jun 11, 2026
7f78c7d
refactor(elementor-v4): refactors, cleanup adds comments
vraja-pro Jun 11, 2026
a2a7cbf
fix(elementor-v4): wrap widget output in <a> when a widget-level link…
vraja-pro Jun 11, 2026
197735a
fix(elementor-v4): update editor-data tests for single-step DOM selector
vraja-pro Jun 11, 2026
b9bd670
fix(elementor-v4): fix image DOM selector and always prefer live DOM …
vraja-pro Jun 11, 2026
bed778b
fix(elementor-v4): strip newlines and tabs from extracted content
vraja-pro Jun 12, 2026
5b0b49f
feat(elementor-v4): exclude button and rating widgets from analysis
vraja-pro Jun 15, 2026
2b01e75
fix(elementor-v4): unwrap Backbone collections in walkAtomicTree
vraja-pro Jun 15, 2026
424209e
refactor(elementor-v4): reduce complexity in content-walker and add p…
vraja-pro Jun 15, 2026
8d295eb
refactor(content-walker): streamline widget HTML extraction and posit…
FAMarfuaty Jun 17, 2026
6f90f18
refactor(elementor-v4): simplify atomic editor activation check
FAMarfuaty Jun 18, 2026
35fcb39
fix(elementor-v4): enhance content extraction with MutationObserver f…
FAMarfuaty Jun 18, 2026
d6c23c3
refactor(content-walker): standardize terminology and improve documen…
FAMarfuaty Jun 18, 2026
64ae41a
test(elementor-v4): add tests for widget recursion and atomic gate be…
FAMarfuaty Jun 18, 2026
fa52ef7
test(elementor-v4): add tests for first-load content observer functio…
FAMarfuaty Jun 18, 2026
fb4fcab
fix(yoastseo): narrow icon-box filter to exclude only the icon, not t…
vraja-pro Jun 18, 2026
e0bfd5d
fix(yoastseo): exclude classic Elementor button text from analysis in V4
vraja-pro Jun 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions admin/class-admin-asset-manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ protected function scripts_to_be_registered() {
'wincher-dashboard-widget' => [ self::PREFIX . 'api-client' ],
'editor-modules' => [ 'jquery' ],
'elementor' => $elementor_dependencies,
'elementor-v4' => array_merge( [ self::PREFIX . 'elementor' ], $elementor_dependencies ),
'indexation' => [
Comment thread
Copilot marked this conversation as resolved.
'jquery-ui-core',
'jquery-ui-progressbar',
Expand Down
1 change: 1 addition & 0 deletions config/webpack/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const getEntries = ( sourceDirectory = "./packages/js/src" ) => ( {
"edit-page": `${ sourceDirectory }/edit-page.js`,
"editor-modules": `${ sourceDirectory }/editor-modules.js`,
elementor: `${ sourceDirectory }/elementor/initialize.js`,
"elementor-v4": `${ sourceDirectory }/elementor-v4/initialize.js`,
"externals-components": `${ sourceDirectory }/externals/components.js`,
"externals-contexts": `${ sourceDirectory }/externals/contexts.js`,
"externals-redux": `${ sourceDirectory }/externals/redux.js`,
Expand Down
1 change: 1 addition & 0 deletions packages/js/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ module.exports = {
"/tests/setupTests.js",
"/tests/decorator/__mocks__/@wordpress/rich-text/index.js",
"/tests/test-utils.js",
"/__fixtures__/",
],
// https://testing-library.com/docs/react-testing-library/setup#jest-27
testEnvironment: "jest-environment-jsdom",
Expand Down
78 changes: 78 additions & 0 deletions packages/js/src/elementor-v4/change-handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/* global elementor */
import { dispatch, select } from "@wordpress/data";
import { debounce } from "lodash";
import { refreshDelay } from "../analysis/constants";
import { isFormIdEqualToDocumentId } from "../elementor/helpers/is-form-id";
import { getEditorData } from "./editor-data";

const editorData = {
content: "",
title: "",
excerpt: "",
imageUrl: "",
featuredImage: "",
contentImage: "",
excerptOnly: "",
};

/* eslint-disable complexity */
/**
* Dispatches updated editor data when the document changes.
*
* @returns {void}
*/
function handleEditorChange() {
const currentDocument = elementor.documents.getCurrent();

if ( ! isFormIdEqualToDocumentId() ) {
return;
}

if ( ! [ "wp-post", "wp-page" ].includes( currentDocument.config.type ) ) {
return;
}

if ( select( "yoast-seo/editor" ).getActiveMarker() ) {
return;
}

const data = getEditorData( currentDocument );

if ( data.content !== editorData.content ) {
editorData.content = data.content;
dispatch( "yoast-seo/editor" ).setEditorDataContent( editorData.content );
}

if ( data.title !== editorData.title ) {
editorData.title = data.title;
dispatch( "yoast-seo/editor" ).setEditorDataTitle( editorData.title );
}

if ( data.excerpt !== editorData.excerpt ) {
editorData.excerpt = data.excerpt;
editorData.excerptOnly = data.excerptOnly;
dispatch( "yoast-seo/editor" ).setEditorDataExcerpt( editorData.excerpt );
dispatch( "yoast-seo/editor" ).updateReplacementVariable( "excerpt", editorData.excerpt );
dispatch( "yoast-seo/editor" ).updateReplacementVariable( "excerpt_only", editorData.excerptOnly );
}

if ( data.imageUrl !== editorData.imageUrl ) {
editorData.imageUrl = data.imageUrl;
dispatch( "yoast-seo/editor" ).setEditorDataImageUrl( editorData.imageUrl );
}

if ( data.contentImage !== editorData.contentImage ) {
editorData.contentImage = data.contentImage;
dispatch( "yoast-seo/editor" ).setContentImage( editorData.contentImage );
}

if ( data.featuredImage !== editorData.featuredImage ) {
editorData.featuredImage = data.featuredImage;
dispatch( "yoast-seo/editor" ).updateData( { snippetPreviewImageURL: editorData.featuredImage } );
}
}
/* eslint-enable complexity */

const debouncedHandleEditorChange = debounce( handleEditorChange, refreshDelay );

export { handleEditorChange, debouncedHandleEditorChange };
122 changes: 122 additions & 0 deletions packages/js/src/elementor-v4/content-walker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* @file Reads rendered widget HTML from the Elementor V4 preview DOM and builds the
* analyzer content plus a per-widget position map in a single pass.
*
* This approach is used instead of reconstructing JSON because this approach includes every widget's
* rendered HTML (classes intact) and leaves the decision of what to exclude to
* yoastseo package's `alwaysFilterElements`. Because the analyzer computes mark positions
* against the original (pre-filter) HTML, the offsets recorded here stay aligned
* with the marks even after the parser strips unwanted widgets.
*/

/**
* @typedef {Object} WidgetEntry
* @property {string} id Widget node ID (matches `data-id` / `data-interaction-id`).
* @property {string} widgetType The widget type (e.g. "e-heading", "table-of-contents").
* @property {number} start Start offset in the normalized concatenated content string.
* @property {number} end End offset (exclusive) in the normalized concatenated content string.
*/

// Editor-only chrome rendered inside classic widget wrappers; never part of the content.
const CHROME_SELECTOR = ".elementor-element-overlay, .elementor-background-overlay, .ui-resizable-handle";

/**
* Converts a Backbone Collection to a plain array via its toJSON() method. model.toJSON()
* does a shallow clone, so nested `elements` stay as Backbone Collections; this is called at
* every level, so `Array.isArray` always passes for the level being walked.
*
* @param {*} nodes The value to normalize.
* @returns {*} A plain array if nodes had toJSON(), otherwise the original value unchanged.
*/
function toPlainNodes( nodes ) {
return typeof nodes?.toJSON === "function" ? nodes.toJSON() : nodes;
}

/**
* Finds the rendered DOM element for a widget node in the live preview. Widgets are matched by
* either `data-id` (on the `.elementor-element` wrapper) or `data-interaction-id` (set by atomic
* widgets); both are matched in one selector and the outermost match wins.
*
* @param {Object} node A document tree node with an `id`.
* @param {Object} $element The current document's preview jQuery element.
* @returns {Element|null} The widget element, or null if not found.
*/
function findWidgetElement( node, $element ) {
return $element?.find( `[data-id="${ node.id }"], [data-interaction-id="${ node.id }"]` ).get( 0 ) ?? null;
}

/**
* Returns the rendered HTML for a single widget element. When the matched element is an
* `.elementor-element` wrapper it carries editor chrome (overlays, resize handles), which is
* stripped from a clone before serializing so it never reaches the analysis. An element that is
* not a wrapper (e.g. an atomic widget's bare tag matched by `data-interaction-id`) is returned
* as-is.
*
* @param {Element} el The widget DOM element.
* @returns {string} The widget's content HTML.
*/
function readWidgetHtml( el ) {
if ( ! el.classList.contains( "elementor-element" ) ) {
return el.outerHTML;
}
const clone = el.cloneNode( true );
clone.querySelectorAll( CHROME_SELECTOR ).forEach( ( node ) => node.remove() );
return clone.outerHTML;
}

/**
* Builds the content and relative widget map contributed by a single widget node. Returns an
* empty result when the widget has no id, is not yet rendered in the preview, or contributes
* no HTML. Offsets are relative to the widget's own content; the caller shifts them.
*
* @param {Object} node A widget tree node.
* @param {Object} $element The current document's preview jQuery element.
* @returns {{ content: string, widgets: WidgetEntry[] }} The widget's content and map entry.
*/
function readWidgetNode( node, $element ) {
if ( ! node.id ) {
return { content: "", widgets: [] };
}
const el = findWidgetElement( node, $element );
if ( ! el ) {
return { content: "", widgets: [] };
}
const html = readWidgetHtml( el ).replace( /[\n\t]/g, "" );
if ( ! html ) {
return { content: "", widgets: [] };
}
return { content: html, widgets: [ { id: node.id, widgetType: node.widgetType, start: 0, end: html.length } ] };
}

/**
* Walks the document tree in order, reads each widget's rendered HTML from the preview DOM,
* and builds the concatenated content string plus a per-widget position map in a single pass.
* Container nodes (flexbox, div-block, …) hold no content of their own, so only their children
* are walked; widget nodes are leaves because their rendered HTML already contains any nested
* widgets. `\n` and `\t` are stripped per widget so offsets match the dispatched content.
*
* @param {Object[]|Object} nodes The document tree array (or a Backbone Collection of it).
* @param {Object} $element The current document's preview jQuery element.
* @returns {{ content: string, widgets: WidgetEntry[] }} The content and widget map.
*/
export function buildContentAndMap( nodes, $element ) {
nodes = toPlainNodes( nodes );
if ( ! Array.isArray( nodes ) ) {
return { content: "", widgets: [] };
}

let content = "";
const widgets = [];

for ( const node of nodes ) {
if ( ! node || typeof node !== "object" ) {
continue;
}
const part = node.elType === "widget" ? readWidgetNode( node, $element ) : buildContentAndMap( node.elements, $element );
const offset = content.length;
widgets.push( ...part.widgets.map( ( w ) => ( { ...w, start: w.start + offset, end: w.end + offset } ) ) );
content += part.content;
}

return { content, widgets };
}
33 changes: 33 additions & 0 deletions packages/js/src/elementor-v4/document-tree.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Tries to extract the array of elements from the live container model.
*
* @param {Object} currentDocument The Elementor document.
* @returns {Array|null} The array of elements, or null if not accessible.
*/
function getContainerElements( currentDocument ) {
const elements = currentDocument.container?.model?.get( "elements" );
if ( ! elements || typeof elements.toJSON !== "function" ) {
return null;
}
return elements.toJSON();
}

/**
* Reads the atomic widget JSON tree from an Elementor document, trying the live
* container model first and falling back to the initial config.
*
* @param {Object} currentDocument The Elementor document.
* @returns {Array} The top-level elements array, or empty if not accessible.
*/
function getDocumentTree( currentDocument ) {
if ( ! currentDocument ) {
return [];
}
const fromContainer = getContainerElements( currentDocument );
if ( fromContainer ) {
return fromContainer;
}
return Array.isArray( currentDocument.config?.elements ) ? currentDocument.config.elements : [];
}

export { getContainerElements, getDocumentTree };
54 changes: 54 additions & 0 deletions packages/js/src/elementor-v4/editor-data.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/* global elementor */
import { get } from "lodash";
import firstImageUrlInContent from "../helpers/firstImageUrlInContent";
import { excerptFromContent } from "../helpers/replacementVariableHelpers";
import getContentLocale from "../analysis/getContentLocale";
import { buildContentAndMap } from "./content-walker";
import { getDocumentTree } from "./document-tree";

/**
* Computes the SEO excerpt with a content fallback.
*
* @param {string} content The full extracted content HTML.
* @param {boolean} onlyExcerpt When true, returns only the post excerpt (no fallback).
* @returns {string} The excerpt string.
*/
function getExcerpt( content, onlyExcerpt = false ) {
let excerpt = elementor.settings.page.model.get( "post_excerpt" );

if ( onlyExcerpt ) {
return excerpt || "";
}

if ( ! excerpt ) {
const limit = ( getContentLocale() === "ja" ) ? 80 : 156;
excerpt = excerptFromContent( content, limit );
}

return excerpt;
}

/**
* Builds the editor data snapshot from the current Elementor document. The content is read
* from the rendered preview DOM (see content-walker), so unwanted widgets (table of contents,
* forms, …) are left in and filtered out downstream by yoastseo's `alwaysFilterElements`.
*
* @param {Object} editorDocument The current document.
* @returns {Object} The editor data.
*/
export const getEditorData = ( editorDocument ) => {
const { content } = buildContentAndMap( getDocumentTree( editorDocument ), editorDocument.$element );
const featuredImageUrl = get( elementor.settings.page.model.get( "post_featured_image" ), "url", "" );
const contentImageUrl = firstImageUrlInContent( content );

return {
content,
title: elementor.settings.page.model.get( "post_title" ),
excerpt: getExcerpt( content ),
excerptOnly: getExcerpt( content, true ),
imageUrl: featuredImageUrl || contentImageUrl,
featuredImage: featuredImageUrl,
contentImage: contentImageUrl,
status: elementor.settings.page.model.get( "post_status" ),
};
};
Loading
Loading