diff --git a/admin/class-admin-asset-manager.php b/admin/class-admin-asset-manager.php index 09485b39176..1c601b597f5 100644 --- a/admin/class-admin-asset-manager.php +++ b/admin/class-admin-asset-manager.php @@ -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' => [ 'jquery-ui-core', 'jquery-ui-progressbar', diff --git a/config/webpack/paths.js b/config/webpack/paths.js index ed42efbbc78..a744d82145e 100644 --- a/config/webpack/paths.js +++ b/config/webpack/paths.js @@ -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`, diff --git a/packages/js/jest.config.js b/packages/js/jest.config.js index d8f4af379ac..dee1f4b5bc3 100644 --- a/packages/js/jest.config.js +++ b/packages/js/jest.config.js @@ -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", diff --git a/packages/js/src/elementor-v4/change-handler.js b/packages/js/src/elementor-v4/change-handler.js new file mode 100644 index 00000000000..0969ef3ac99 --- /dev/null +++ b/packages/js/src/elementor-v4/change-handler.js @@ -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 }; diff --git a/packages/js/src/elementor-v4/content-walker.js b/packages/js/src/elementor-v4/content-walker.js new file mode 100644 index 00000000000..0ccdb423ae9 --- /dev/null +++ b/packages/js/src/elementor-v4/content-walker.js @@ -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 }; +} diff --git a/packages/js/src/elementor-v4/document-tree.js b/packages/js/src/elementor-v4/document-tree.js new file mode 100644 index 00000000000..087c9bb9f7c --- /dev/null +++ b/packages/js/src/elementor-v4/document-tree.js @@ -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 }; diff --git a/packages/js/src/elementor-v4/editor-data.js b/packages/js/src/elementor-v4/editor-data.js new file mode 100644 index 00000000000..a006804c629 --- /dev/null +++ b/packages/js/src/elementor-v4/editor-data.js @@ -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" ), + }; +}; diff --git a/packages/js/src/elementor-v4/initialize.js b/packages/js/src/elementor-v4/initialize.js new file mode 100644 index 00000000000..a5450668807 --- /dev/null +++ b/packages/js/src/elementor-v4/initialize.js @@ -0,0 +1,96 @@ +/* global elementor */ +/** + * @file Elementor V4 atomic editor entry. Extracts content from the rendered preview DOM + * and dispatches it to the Yoast editor store. + */ + +import { debounce, noop } from "lodash"; +import { refreshDelay } from "../analysis/constants"; +import { registerElementorUIHookAfter, registerElementorUIHookBefore } from "../elementor/helpers/hooks"; +import { isFormId, isFormIdEqualToDocumentId } from "../elementor/helpers/is-form-id"; +import { handleEditorChange, debouncedHandleEditorChange } from "./change-handler"; +import { resetMarks, getWidgetMap } from "./marks"; + +// Expose V4 utilities so premium's mark applicator can locate widgets by ID +// without re-implementing the content-walker tree traversal. +window.yoastElementorV4 = { getWidgetMap }; + +/** + * Initializes the content watcher. + * + * @returns {void} + */ +function initializeElementorV4() { + let stopObserver = noop; + + registerElementorUIHookAfter( + "editor/documents/attach-preview", + "yoast-seo/v4/content-walker/start-observer", + () => { + stopObserver(); + + // The preview DOM renders asynchronously after this hook fires, so a single read now + // would miss the initial content. A MutationObserver re-runs the extraction once + // Elementor has rendered the widgets (and again when the panel opens), the same way + // the legacy watcher handles `$element` loading in later. + const previewElement = elementor.documents.getCurrent()?.$element?.get( 0 ); + const observer = new MutationObserver( debouncedHandleEditorChange ); + if ( previewElement ) { + observer.observe( previewElement, { attributes: true, childList: true, subtree: true, characterData: true } ); + } + elementor.channels.editor.on( "change", debouncedHandleEditorChange ); + elementor.settings.page.model.on( "change", debouncedHandleEditorChange ); + + stopObserver = () => { + observer.disconnect(); + elementor.channels.editor.off( "change", debouncedHandleEditorChange ); + elementor.settings.page.model.off( "change", debouncedHandleEditorChange ); + }; + + // Read now in case the content is already rendered; the observer covers the later render. + debouncedHandleEditorChange(); + }, + isFormIdEqualToDocumentId + ); + + registerElementorUIHookBefore( + "panel/editor/open", + "yoast-seo/v4/marks/reset-on-edit", + debounce( resetMarks, refreshDelay ), + isFormIdEqualToDocumentId + ); + + registerElementorUIHookBefore( + "document/save/save", + "yoast-seo/v4/marks/reset-on-save", + resetMarks, + ( { document } ) => isFormId( document?.id || elementor.documents.getCurrent().id ) + ); + + registerElementorUIHookAfter( + "editor/documents/close", + "yoast-seo/v4/content-walker/stop", + () => { + stopObserver(); + stopObserver = noop; + debouncedHandleEditorChange.cancel(); + }, + ( { id } ) => isFormId( id ) + ); + + registerElementorUIHookAfter( + "document/save/set-is-modified", + "yoast-seo/v4/content-walker/on-modified", + debouncedHandleEditorChange, + ( { document } ) => isFormId( document?.id || elementor.documents.getCurrent().id ) + ); + + handleEditorChange(); +} + +jQuery( window ).on( "elementor:init", () => { + window.elementor.on( "panel:init", () => { + // Defer to let Elementor finish setting up the panel before we register hooks. + setTimeout( initializeElementorV4 ); + } ); +} ); diff --git a/packages/js/src/elementor-v4/marks.js b/packages/js/src/elementor-v4/marks.js new file mode 100644 index 00000000000..9c6f9a1eba8 --- /dev/null +++ b/packages/js/src/elementor-v4/marks.js @@ -0,0 +1,37 @@ +/* global elementor, YoastSEO */ +import { dispatch } from "@wordpress/data"; +import { Paper } from "yoastseo"; +import { buildContentAndMap } from "./content-walker"; +import { getDocumentTree } from "./document-tree"; + +/** + * Clears the active marker and removes any existing highlight marks. + * + * Dispatching an empty Paper through `applyMarks` lets the registered Elementor mark + * applicator strip the `` spans it previously wrote into the preview DOM. + * + * @returns {void} + */ +function resetMarks() { + dispatch( "yoast-seo/editor" ).setActiveMarker( null ); + dispatch( "yoast-seo/editor" ).setMarkerPauseStatus( false ); + + YoastSEO.analysis.applyMarks( new Paper( "", {} ), [] ); +} + +/** + * Returns per-widget position metadata for the current Elementor document. + * + * Each entry maps a widget node ID to its range in the normalized analysis + * content string, so premium's mark applicator can apply marks at the correct + * local offset without re-implementing the tree walk. + * + * @param {Object} [currentDocument] The Elementor document (defaults to current). + * @returns {import("./content-walker").WidgetEntry[]} Array of widget entries. + */ +export function getWidgetMap( currentDocument = elementor.documents.getCurrent() ) { + const tree = getDocumentTree( currentDocument ); + return buildContentAndMap( tree, currentDocument.$element ).widgets; +} + +export { resetMarks }; diff --git a/packages/js/src/elementor/initialize.js b/packages/js/src/elementor/initialize.js index 1ec34258801..2fa04bcb784 100644 --- a/packages/js/src/elementor/initialize.js +++ b/packages/js/src/elementor/initialize.js @@ -14,6 +14,30 @@ import initializeUsedKeywords from "./initializers/used-keywords-assessment"; import initReplaceVarPlugin, { addReplacement, ReplaceVar } from "./replaceVars/elementor-replacevar-plugin"; +/** + * Returns true when Elementor's V4 atomic editor is active. + * + * @returns {boolean} Whether V4 atomic editor mode is active. + */ +function isElementorV4AtomicActive() { + const flag = window.wpseoScriptData?.isElementorV4Atomic; + // WP localised booleans may arrive as `true` or `"1"`. + return flag === true || flag === "1"; +} + +/** + * Initializes the appropriate introduction component based on feature flags. + * + * @returns {void} + */ +function initIntroductionComponent() { + if ( window.wpseoScriptData.isAlwaysIntroductionV2 === "1" || window.elementorFrontend.config.experimentalFeatures.editor_v2 ) { + initializeIntroductionEditorV2(); + return; + } + initializeIntroduction(); +} + /** * Initializes Yoast SEO for Elementor. * @@ -24,8 +48,11 @@ function initialize() { window.YoastSEO = window.YoastSEO || {}; window.YoastSEO.store = initEditorStore(); - // Initialize the editor data watcher. - initElementorWatcher(); + // The V4 atomic editor uses its own watcher (`elementor-v4` bundle) so the legacy + // DOM watcher is skipped to avoid double-dispatch on `setEditorDataContent`. + if ( ! isElementorV4AtomicActive() ) { + initElementorWatcher(); + } /* * Expose pluggable. @@ -63,11 +90,7 @@ function initialize() { initHighlightFocusKeyphraseForms( window.YoastSEO.analysis.worker.runResearch ); // Initialize the introduction. - if ( window.wpseoScriptData.isAlwaysIntroductionV2 === "1" || window.elementorFrontend.config.experimentalFeatures.editor_v2 ) { - initializeIntroductionEditorV2(); - } else { - initializeIntroduction(); - } + initIntroductionComponent(); // Initialize the editor integration. initializeElementEditorIntegration(); diff --git a/packages/js/tests/elementor-v4/change-handler.test.js b/packages/js/tests/elementor-v4/change-handler.test.js new file mode 100644 index 00000000000..372ab6e40fe --- /dev/null +++ b/packages/js/tests/elementor-v4/change-handler.test.js @@ -0,0 +1,197 @@ +/* global elementor */ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; + +// @wordpress/data is a third-party module — its factory is called lazily after variable +// initialization, so const mock-prefixed variables work safely here. +const mockSetEditorDataContent = jest.fn(); +const mockSetEditorDataTitle = jest.fn(); +const mockSetEditorDataExcerpt = jest.fn(); +const mockSetEditorDataImageUrl = jest.fn(); +const mockSetContentImage = jest.fn(); +const mockUpdateData = jest.fn(); +const mockUpdateReplacementVariable = jest.fn(); +const mockGetActiveMarker = jest.fn(); + +jest.mock( "@wordpress/data", () => ( { + dispatch: () => ( { + setEditorDataContent: mockSetEditorDataContent, + setEditorDataTitle: mockSetEditorDataTitle, + setEditorDataExcerpt: mockSetEditorDataExcerpt, + setEditorDataImageUrl: mockSetEditorDataImageUrl, + setContentImage: mockSetContentImage, + updateData: mockUpdateData, + updateReplacementVariable: mockUpdateReplacementVariable, + } ), + select: () => ( { + getActiveMarker: mockGetActiveMarker, + } ), +} ) ); + +// Local modules — auto-mock only; import gives a reference to the jest.fn(). +jest.mock( "../../src/elementor/helpers/is-form-id" ); +jest.mock( "../../src/elementor-v4/editor-data" ); + +import { isFormIdEqualToDocumentId } from "../../src/elementor/helpers/is-form-id"; +import { getEditorData } from "../../src/elementor-v4/editor-data"; +import { handleEditorChange } from "../../src/elementor-v4/change-handler"; + +const makeCurrentDocument = ( type = "wp-post" ) => ( { config: { type } } ); + +const makeData = ( overrides = {} ) => ( { + content: "", + title: "", + excerpt: "", + excerptOnly: "", + imageUrl: "", + featuredImage: "", + contentImage: "", + ...overrides, +} ); + +global.elementor = { + documents: { getCurrent: jest.fn().mockReturnValue( makeCurrentDocument() ) }, +}; + +beforeEach( () => { + jest.clearAllMocks(); + mockGetActiveMarker.mockReturnValue( null ); + isFormIdEqualToDocumentId.mockReturnValue( true ); + elementor.documents.getCurrent.mockReturnValue( makeCurrentDocument() ); + getEditorData.mockReturnValue( makeData() ); +} ); + +describe( "handleEditorChange — early exits", () => { + it( "does nothing when the form ID does not match the document ID", () => { + isFormIdEqualToDocumentId.mockReturnValue( false ); + + handleEditorChange(); + + expect( getEditorData ).not.toHaveBeenCalled(); + expect( mockSetEditorDataContent ).not.toHaveBeenCalled(); + } ); + + it( "does nothing when the document type is not wp-post or wp-page", () => { + elementor.documents.getCurrent.mockReturnValue( makeCurrentDocument( "landing-page" ) ); + + handleEditorChange(); + + expect( getEditorData ).not.toHaveBeenCalled(); + } ); + + it( "does nothing when an active marker is set", () => { + mockGetActiveMarker.mockReturnValue( "some-marker" ); + + handleEditorChange(); + + expect( getEditorData ).not.toHaveBeenCalled(); + } ); + + it( "runs for wp-page document type", () => { + elementor.documents.getCurrent.mockReturnValue( makeCurrentDocument( "wp-page" ) ); + getEditorData.mockReturnValue( makeData( { content: "page content" } ) ); + + handleEditorChange(); + + expect( getEditorData ).toHaveBeenCalled(); + } ); +} ); + +describe( "handleEditorChange — dispatches changed fields", () => { + it( "dispatches content when it changes", () => { + getEditorData.mockReturnValue( makeData( { content: "

Initial

" } ) ); + handleEditorChange(); + + jest.clearAllMocks(); + getEditorData.mockReturnValue( makeData( { content: "

Updated

" } ) ); + handleEditorChange(); + + expect( mockSetEditorDataContent ).toHaveBeenCalledWith( "

Updated

" ); + } ); + + it( "dispatches title when it changes", () => { + getEditorData.mockReturnValue( makeData( { title: "Original" } ) ); + handleEditorChange(); + + jest.clearAllMocks(); + getEditorData.mockReturnValue( makeData( { title: "Revised" } ) ); + handleEditorChange(); + + expect( mockSetEditorDataTitle ).toHaveBeenCalledWith( "Revised" ); + } ); + + it( "dispatches excerpt and both replacement variables when excerpt changes", () => { + getEditorData.mockReturnValue( makeData( { excerpt: "First", excerptOnly: "First" } ) ); + handleEditorChange(); + + jest.clearAllMocks(); + getEditorData.mockReturnValue( makeData( { excerpt: "Updated", excerptOnly: "Updated only" } ) ); + handleEditorChange(); + + expect( mockSetEditorDataExcerpt ).toHaveBeenCalledWith( "Updated" ); + expect( mockUpdateReplacementVariable ).toHaveBeenCalledWith( "excerpt", "Updated" ); + expect( mockUpdateReplacementVariable ).toHaveBeenCalledWith( "excerpt_only", "Updated only" ); + } ); + + it( "dispatches imageUrl when it changes", () => { + getEditorData.mockReturnValue( makeData( { imageUrl: "https://example.com/old.jpg" } ) ); + handleEditorChange(); + + jest.clearAllMocks(); + getEditorData.mockReturnValue( makeData( { imageUrl: "https://example.com/new.jpg" } ) ); + handleEditorChange(); + + expect( mockSetEditorDataImageUrl ).toHaveBeenCalledWith( "https://example.com/new.jpg" ); + } ); + + it( "dispatches contentImage when it changes", () => { + getEditorData.mockReturnValue( makeData( { contentImage: "https://example.com/old.jpg" } ) ); + handleEditorChange(); + + jest.clearAllMocks(); + getEditorData.mockReturnValue( makeData( { contentImage: "https://example.com/new.jpg" } ) ); + handleEditorChange(); + + expect( mockSetContentImage ).toHaveBeenCalledWith( "https://example.com/new.jpg" ); + } ); + + it( "dispatches featuredImage via updateData when it changes", () => { + getEditorData.mockReturnValue( makeData( { featuredImage: "https://example.com/old.jpg" } ) ); + handleEditorChange(); + + jest.clearAllMocks(); + getEditorData.mockReturnValue( makeData( { featuredImage: "https://example.com/new.jpg" } ) ); + handleEditorChange(); + + expect( mockUpdateData ).toHaveBeenCalledWith( { snippetPreviewImageURL: "https://example.com/new.jpg" } ); + } ); +} ); + +describe( "handleEditorChange — does not re-dispatch unchanged fields", () => { + it( "does not dispatch any action when no field has changed", () => { + const data = makeData( { content: "Same content", title: "Same title" } ); + getEditorData.mockReturnValue( data ); + + handleEditorChange(); + jest.clearAllMocks(); + handleEditorChange(); + + expect( mockSetEditorDataContent ).not.toHaveBeenCalled(); + expect( mockSetEditorDataTitle ).not.toHaveBeenCalled(); + expect( mockSetEditorDataExcerpt ).not.toHaveBeenCalled(); + expect( mockSetEditorDataImageUrl ).not.toHaveBeenCalled(); + expect( mockSetContentImage ).not.toHaveBeenCalled(); + expect( mockUpdateData ).not.toHaveBeenCalled(); + } ); + + it( "dispatches only the field that changed, not unchanged siblings", () => { + getEditorData.mockReturnValue( makeData( { content: "Content A", title: "Title A" } ) ); + handleEditorChange(); + + jest.clearAllMocks(); + getEditorData.mockReturnValue( makeData( { content: "Content B", title: "Title A" } ) ); + handleEditorChange(); + + expect( mockSetEditorDataContent ).toHaveBeenCalled(); + expect( mockSetEditorDataTitle ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/packages/js/tests/elementor-v4/content-walker.test.js b/packages/js/tests/elementor-v4/content-walker.test.js new file mode 100644 index 00000000000..4c0b394af60 --- /dev/null +++ b/packages/js/tests/elementor-v4/content-walker.test.js @@ -0,0 +1,177 @@ +import { describe, expect, it } from "@jest/globals"; + +import { buildContentAndMap } from "../../src/elementor-v4/content-walker"; + +/** + * Builds a fake preview `$element` backed by a real jsdom container, exposing the + * jQuery-ish `.find( selector ).get( index )` interface the walker relies on. + * + * @param {string} html The preview markup. + * @returns {Object} A stub with the `$element` the walker expects. + */ +function makePreview( html ) { + const root = document.createElement( "div" ); + root.innerHTML = html; + return { + find: ( selector ) => { + const el = root.querySelector( selector ); + return { get: ( index ) => ( index === 0 ? el : null ) }; + }, + }; +} + +const widget = ( id, widgetType ) => ( { elType: "widget", id, widgetType } ); +const container = ( elements ) => ( { elType: "container", elements } ); + +// A classic widget wrapper, with editor chrome that must be stripped from the content. +const classicWidget = ( id, type, inner ) => + `
` + + `
CHROME
${ inner }
`; + +describe( "buildContentAndMap", () => { + it( "returns empty for null, undefined, object, and string input", () => { + const empty = { content: "", widgets: [] }; + expect( buildContentAndMap( null, makePreview( "" ) ) ).toEqual( empty ); + expect( buildContentAndMap( undefined, makePreview( "" ) ) ).toEqual( empty ); + expect( buildContentAndMap( {}, makePreview( "" ) ) ).toEqual( empty ); + expect( buildContentAndMap( "text", makePreview( "" ) ) ).toEqual( empty ); + expect( buildContentAndMap( [], makePreview( "" ) ) ).toEqual( empty ); + } ); + + it( "reads a classic widget's rendered HTML and strips editor chrome", () => { + const $el = makePreview( classicWidget( "w1", "heading", "

Hello

" ) ); + const { content, widgets } = buildContentAndMap( [ widget( "w1", "heading" ) ], $el ); + + expect( content ).toContain( "

Hello

" ); + expect( content ).not.toContain( "CHROME" ); + expect( widgets ).toHaveLength( 1 ); + expect( widgets[ 0 ] ).toMatchObject( { id: "w1", widgetType: "heading", start: 0 } ); + expect( content.slice( widgets[ 0 ].start, widgets[ 0 ].end ) ).toBe( content ); + } ); + + it( "reads an atomic widget rendered as a bare semantic tag as-is", () => { + const $el = makePreview( "

Atomic

" ); + const { content, widgets } = buildContentAndMap( [ widget( "a1", "e-heading" ) ], $el ); + + expect( content ).toBe( "

Atomic

" ); + expect( widgets[ 0 ] ).toMatchObject( { id: "a1", widgetType: "e-heading" } ); + } ); + + it( "matches the data-id wrapper when both data-id and data-interaction-id are present", () => { + const $el = makePreview( + "
" + + "

Wrapped

" + ); + const { content } = buildContentAndMap( [ widget( "x", "e-heading" ) ], $el ); + // The wrapper div is matched first (document order), so its outerHTML wraps the heading. + expect( content.startsWith( "
{ + const $el = makePreview( + classicWidget( "w1", "heading", "

One

" ) + + classicWidget( "w2", "text-editor", "

Two

" ) + ); + const { content, widgets } = buildContentAndMap( + [ widget( "w1", "heading" ), widget( "w2", "text-editor" ) ], $el + ); + + expect( widgets ).toHaveLength( 2 ); + expect( widgets[ 0 ].start ).toBe( 0 ); + expect( widgets[ 0 ].end ).toBe( widgets[ 1 ].start ); + expect( widgets[ 1 ].end ).toBe( content.length ); + expect( content.slice( widgets[ 1 ].start, widgets[ 1 ].end ) ).toContain( "

Two

" ); + } ); + + it( "walks container children and shifts their offsets by preceding content", () => { + const $el = makePreview( + classicWidget( "outer", "heading", "

Outer

" ) + + classicWidget( "inner", "text-editor", "

Inner

" ) + ); + const tree = [ + widget( "outer", "heading" ), + container( [ widget( "inner", "text-editor" ) ] ), + ]; + const { content, widgets } = buildContentAndMap( tree, $el ); + + const outer = widgets.find( ( w ) => w.id === "outer" ); + const inner = widgets.find( ( w ) => w.id === "inner" ); + expect( inner.start ).toBe( outer.end ); + expect( content.slice( inner.start, inner.end ) ).toContain( "

Inner

" ); + } ); + + it( "skips a widget that is not rendered in the preview, keeping later offsets at 0", () => { + const $el = makePreview( classicWidget( "present", "heading", "

Here

" ) ); + const { widgets } = buildContentAndMap( + [ widget( "missing", "heading" ), widget( "present", "heading" ) ], $el + ); + + expect( widgets ).toHaveLength( 1 ); + expect( widgets[ 0 ] ).toMatchObject( { id: "present", start: 0 } ); + } ); + + it( "skips a widget node without an id", () => { + const $el = makePreview( classicWidget( "w1", "heading", "

Has id

" ) ); + const noId = { elType: "widget", widgetType: "heading" }; + const { widgets } = buildContentAndMap( [ noId, widget( "w1", "heading" ) ], $el ); + + expect( widgets ).toHaveLength( 1 ); + expect( widgets[ 0 ].id ).toBe( "w1" ); + } ); + + it( "strips \\n and \\t so positions match the normalised content", () => { + const $el = makePreview( "

Line\tone\ntwo

" ); + const { content, widgets } = buildContentAndMap( [ widget( "t", "e-paragraph" ) ], $el ); + + expect( content ).not.toMatch( /[\n\t]/ ); + expect( widgets[ 0 ].end - widgets[ 0 ].start ).toBe( content.length ); + } ); + + it( "unwraps a Backbone collection (toJSON) at any nesting level", () => { + const $el = makePreview( classicWidget( "deep", "heading", "

Deep

" ) ); + const tree = [ { + elType: "container", + elements: { toJSON: () => [ widget( "deep", "heading" ) ] }, + } ]; + const { content, widgets } = buildContentAndMap( tree, $el ); + + expect( content ).toContain( "

Deep

" ); + expect( widgets[ 0 ].id ).toBe( "deep" ); + } ); + + it( "every widget span is a valid, non-overlapping slice of the content", () => { + const $el = makePreview( + classicWidget( "w1", "heading", "

First

" ) + + classicWidget( "w2", "text-editor", "

Second

" ) + ); + const { content, widgets } = buildContentAndMap( + [ widget( "w1", "heading" ), widget( "w2", "text-editor" ) ], $el + ); + + widgets.forEach( ( w ) => { + expect( w.start ).toBeGreaterThanOrEqual( 0 ); + expect( w.end ).toBeGreaterThan( w.start ); + expect( w.end ).toBeLessThanOrEqual( content.length ); + } ); + } ); + + it( "does not recurse into a widget node's own children, so nested widgets are not double-counted", () => { + // A widget such as nested-tabs carries child widgets in the model, but its rendered HTML + // already contains them — the walker must read the widget once and not walk its children. + const $el = makePreview( classicWidget( "tabs", "nested-tabs", "

Tab

Panel text

" ) ); + const tree = [ { + elType: "widget", + id: "tabs", + widgetType: "nested-tabs", + elements: [ widget( "panel", "e-paragraph" ) ], + } ]; + const { content, widgets } = buildContentAndMap( tree, $el ); + + expect( widgets ).toHaveLength( 1 ); + expect( widgets[ 0 ].id ).toBe( "tabs" ); + expect( widgets.find( ( w ) => w.id === "panel" ) ).toBeUndefined(); + // "Panel text" appears exactly once (not duplicated by walking the child node). + expect( content.split( "Panel text" ) ).toHaveLength( 2 ); + } ); +} ); diff --git a/packages/js/tests/elementor-v4/document-tree.test.js b/packages/js/tests/elementor-v4/document-tree.test.js new file mode 100644 index 00000000000..e9e0dc2be91 --- /dev/null +++ b/packages/js/tests/elementor-v4/document-tree.test.js @@ -0,0 +1,67 @@ +import { describe, expect, it } from "@jest/globals"; + +import { getContainerElements, getDocumentTree } from "../../src/elementor-v4/document-tree"; + +describe( "getContainerElements", () => { + it( "returns the serialised live elements array from the container model", () => { + const elements = [ { id: "a", widgetType: "e-heading" } ]; + const doc = { + container: { + model: { get: ( key ) => key === "elements" ? { toJSON: () => elements } : null }, + }, + }; + expect( getContainerElements( doc ) ).toEqual( elements ); + } ); + + it( "returns null when container is absent", () => { + expect( getContainerElements( {} ) ).toBeNull(); + expect( getContainerElements( { container: null } ) ).toBeNull(); + } ); + + it( "returns null when model.get('elements') has no toJSON method (plain array, not Backbone Collection)", () => { + const doc = { + container: { model: { get: () => [ { id: "a" } ] } }, + }; + expect( getContainerElements( doc ) ).toBeNull(); + } ); + + it( "returns null when model is absent", () => { + const doc = { container: {} }; + expect( getContainerElements( doc ) ).toBeNull(); + } ); +} ); + +describe( "getDocumentTree", () => { + it( "returns an empty array for null or undefined input", () => { + expect( getDocumentTree( null ) ).toEqual( [] ); + expect( getDocumentTree( undefined ) ).toEqual( [] ); + } ); + + it( "returns live container elements when the container model is available", () => { + const elements = [ { id: "widget-1", widgetType: "e-heading" } ]; + const doc = { + container: { model: { get: () => ( { toJSON: () => elements } ) } }, + }; + expect( getDocumentTree( doc ) ).toEqual( elements ); + } ); + + it( "falls back to config.elements when the container is absent", () => { + const elements = [ { id: "widget-1" } ]; + expect( getDocumentTree( { config: { elements } } ) ).toEqual( elements ); + } ); + + it( "prefers the live container over config.elements when both are present", () => { + const live = [ { id: "live" } ]; + const doc = { + container: { model: { get: () => ( { toJSON: () => live } ) } }, + config: { elements: [ { id: "stale" } ] }, + }; + expect( getDocumentTree( doc ) ).toEqual( live ); + } ); + + it( "returns an empty array when neither container nor config.elements is available", () => { + expect( getDocumentTree( {} ) ).toEqual( [] ); + expect( getDocumentTree( { config: {} } ) ).toEqual( [] ); + expect( getDocumentTree( { config: { elements: "not-an-array" } } ) ).toEqual( [] ); + } ); +} ); diff --git a/packages/js/tests/elementor-v4/editor-data.test.js b/packages/js/tests/elementor-v4/editor-data.test.js new file mode 100644 index 00000000000..c3751a23e27 --- /dev/null +++ b/packages/js/tests/elementor-v4/editor-data.test.js @@ -0,0 +1,146 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; + +// Local module mocks — no factory; auto-mock creates jest.fn() for each export. +// Importing after jest.mock() gives a reference to the mock function. +jest.mock( "../../src/elementor-v4/content-walker" ); +jest.mock( "../../src/elementor-v4/document-tree" ); +jest.mock( "../../src/helpers/firstImageUrlInContent" ); +jest.mock( "../../src/helpers/replacementVariableHelpers" ); +jest.mock( "../../src/analysis/getContentLocale" ); + +import { buildContentAndMap } from "../../src/elementor-v4/content-walker"; +import { getDocumentTree } from "../../src/elementor-v4/document-tree"; +import firstImageUrlInContent from "../../src/helpers/firstImageUrlInContent"; +import { excerptFromContent } from "../../src/helpers/replacementVariableHelpers"; +import getContentLocale from "../../src/analysis/getContentLocale"; +import { getEditorData } from "../../src/elementor-v4/editor-data"; + +const mockPageModelGet = jest.fn(); + +global.elementor = { + settings: { + page: { + model: { get: mockPageModelGet }, + }, + }, +}; + +const PAGE_SETTING_KEYS = { + TITLE: "post_title", + EXCERPT: "post_excerpt", + STATUS: "post_status", + FEATURED_IMAGE: "post_featured_image", +}; + +const defaultSettings = { + [ PAGE_SETTING_KEYS.TITLE ]: "My Post", + [ PAGE_SETTING_KEYS.EXCERPT ]: "", + [ PAGE_SETTING_KEYS.STATUS ]: "draft", + [ PAGE_SETTING_KEYS.FEATURED_IMAGE ]: null, +}; + +// The walker reads from editorDocument.$element; getEditorData passes it straight through. +const makeEditorDocument = () => ( { $element: { find: jest.fn() } } ); + +beforeEach( () => { + jest.clearAllMocks(); + mockPageModelGet.mockImplementation( ( key ) => defaultSettings[ key ] ?? null ); + getDocumentTree.mockReturnValue( [] ); + buildContentAndMap.mockReturnValue( { content: "", widgets: [] } ); + firstImageUrlInContent.mockReturnValue( "" ); + excerptFromContent.mockReturnValue( "generated excerpt" ); + getContentLocale.mockReturnValue( "en" ); +} ); + +describe( "getEditorData", () => { + it( "returns the content produced by buildContentAndMap from the document's preview element", () => { + const tree = [ { id: "h1", widgetType: "e-heading" } ]; + const editorDocument = makeEditorDocument(); + getDocumentTree.mockReturnValue( tree ); + buildContentAndMap.mockReturnValue( { content: "

Hello

", widgets: [] } ); + + const result = getEditorData( editorDocument ); + + expect( getDocumentTree ).toHaveBeenCalledWith( editorDocument ); + expect( buildContentAndMap ).toHaveBeenCalledWith( tree, editorDocument.$element ); + expect( result.content ).toBe( "

Hello

" ); + } ); + + it( "returns title and status from elementor page settings", () => { + mockPageModelGet.mockImplementation( ( key ) => ( { + [ PAGE_SETTING_KEYS.TITLE ]: "SEO Title", + [ PAGE_SETTING_KEYS.STATUS ]: "publish", + [ PAGE_SETTING_KEYS.EXCERPT ]: "", + [ PAGE_SETTING_KEYS.FEATURED_IMAGE ]: null, + } )[ key ] ?? null ); + + const result = getEditorData( makeEditorDocument() ); + + expect( result.title ).toBe( "SEO Title" ); + expect( result.status ).toBe( "publish" ); + } ); + + it( "uses post_excerpt when set", () => { + mockPageModelGet.mockImplementation( ( key ) => key === PAGE_SETTING_KEYS.EXCERPT ? "Hand-written excerpt." : null ); + + const result = getEditorData( makeEditorDocument() ); + + expect( result.excerpt ).toBe( "Hand-written excerpt." ); + expect( excerptFromContent ).not.toHaveBeenCalled(); + } ); + + it( "falls back to excerptFromContent when post_excerpt is absent", () => { + buildContentAndMap.mockReturnValue( { content: "

Long content here.

", widgets: [] } ); + excerptFromContent.mockReturnValue( "Auto-generated excerpt." ); + + const result = getEditorData( makeEditorDocument() ); + + expect( excerptFromContent ).toHaveBeenCalledWith( "

Long content here.

", 156 ); + expect( result.excerpt ).toBe( "Auto-generated excerpt." ); + } ); + + it( "uses a character limit of 80 for the Japanese locale", () => { + getContentLocale.mockReturnValue( "ja" ); + buildContentAndMap.mockReturnValue( { content: "

Content.

", widgets: [] } ); + + getEditorData( makeEditorDocument() ); + + expect( excerptFromContent ).toHaveBeenCalledWith( "

Content.

", 80 ); + } ); + + it( "returns excerptOnly from post_excerpt with no content fallback", () => { + buildContentAndMap.mockReturnValue( { content: "

Content.

", widgets: [] } ); + + const resultNoExcerpt = getEditorData( makeEditorDocument() ); + expect( resultNoExcerpt.excerptOnly ).toBe( "" ); + + mockPageModelGet.mockImplementation( ( key ) => key === PAGE_SETTING_KEYS.EXCERPT ? "My excerpt." : null ); + const resultWithExcerpt = getEditorData( makeEditorDocument() ); + expect( resultWithExcerpt.excerptOnly ).toBe( "My excerpt." ); + } ); + + it( "prefers featuredImageUrl over contentImageUrl for imageUrl", () => { + mockPageModelGet.mockImplementation( ( key ) => { + if ( key === PAGE_SETTING_KEYS.FEATURED_IMAGE ) { + return { url: "https://example.com/featured.jpg" }; + } + return null; + } ); + firstImageUrlInContent.mockReturnValue( "https://example.com/content.jpg" ); + + const result = getEditorData( makeEditorDocument() ); + + expect( result.imageUrl ).toBe( "https://example.com/featured.jpg" ); + expect( result.featuredImage ).toBe( "https://example.com/featured.jpg" ); + } ); + + it( "falls back to contentImageUrl when no featured image is set", () => { + firstImageUrlInContent.mockReturnValue( "https://example.com/content.jpg" ); + + const result = getEditorData( makeEditorDocument() ); + + expect( result.imageUrl ).toBe( "https://example.com/content.jpg" ); + expect( result.contentImage ).toBe( "https://example.com/content.jpg" ); + expect( result.featuredImage ).toBe( "" ); + } ); +} ); diff --git a/packages/js/tests/elementor-v4/initialize.test.js b/packages/js/tests/elementor-v4/initialize.test.js new file mode 100644 index 00000000000..b68f14b91f0 --- /dev/null +++ b/packages/js/tests/elementor-v4/initialize.test.js @@ -0,0 +1,78 @@ +import { describe, expect, it, jest } from "@jest/globals"; + +// Capture the UI-hook callbacks the entry registers, and the dependencies it calls, so the test +// can drive the attach-preview hook in isolation. jest.mock factories may not reference `jest`, so +// the mock functions are declared here (the `mock` prefix lets the lazily-called factories use them). +const mockHooks = {}; +const mockHandleEditorChange = jest.fn(); +const mockDebouncedHandleEditorChange = jest.fn(); +const mockResetMarks = jest.fn(); +const mockGetWidgetMap = jest.fn(); + +jest.mock( "../../src/elementor/helpers/hooks", () => ( { + registerElementorUIHookAfter: ( id, key, callback ) => { + mockHooks[ id ] = callback; + }, + registerElementorUIHookBefore: () => {}, +} ) ); +jest.mock( "../../src/elementor/helpers/is-form-id", () => ( { + isFormId: () => true, + isFormIdEqualToDocumentId: () => true, +} ) ); +jest.mock( "../../src/elementor-v4/change-handler", () => ( { + handleEditorChange: mockHandleEditorChange, + debouncedHandleEditorChange: mockDebouncedHandleEditorChange, +} ) ); +jest.mock( "../../src/elementor-v4/marks", () => ( { + resetMarks: mockResetMarks, + getWidgetMap: mockGetWidgetMap, +} ) ); + +describe( "elementor-v4 initialize — first-load content observer", () => { + it( "observes the preview DOM and triggers extraction on attach-preview", () => { + const previewElement = document.createElement( "div" ); + const elementorMock = { + documents: { getCurrent: () => ( { $element: { get: () => previewElement } } ) }, + channels: { editor: { on: jest.fn(), off: jest.fn() } }, + settings: { page: { model: { on: jest.fn(), off: jest.fn() } } }, + // Invoke the panel:init handler synchronously so initialization runs during require(). + on: ( event, callback ) => { + if ( event === "panel:init" ) { + callback(); + } + }, + }; + global.elementor = elementorMock; + global.window.elementor = elementorMock; + // Invoke the elementor:init handler synchronously. + global.jQuery = () => ( { + on: ( event, callback ) => { + if ( event === "elementor:init" ) { + callback(); + } + }, + } ); + + const observeSpy = jest.spyOn( MutationObserver.prototype, "observe" ); + jest.useFakeTimers(); + + // Importing the entry wires the elementor:init → panel:init → setTimeout chain. + require( "../../src/elementor-v4/initialize" ); + jest.runOnlyPendingTimers(); + + // The attach-preview hook is where the observer is started. + expect( typeof mockHooks[ "editor/documents/attach-preview" ] ).toBe( "function" ); + mockHooks[ "editor/documents/attach-preview" ](); + + // The fix: a MutationObserver watches the preview so the first render is picked up... + expect( observeSpy ).toHaveBeenCalledWith( + previewElement, + expect.objectContaining( { childList: true, subtree: true } ) + ); + // ...and an extraction is kicked off. + expect( mockDebouncedHandleEditorChange ).toHaveBeenCalled(); + + observeSpy.mockRestore(); + jest.useRealTimers(); + } ); +} ); diff --git a/packages/js/tests/elementor-v4/marks.test.js b/packages/js/tests/elementor-v4/marks.test.js new file mode 100644 index 00000000000..320af104767 --- /dev/null +++ b/packages/js/tests/elementor-v4/marks.test.js @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; + +const mockSetActiveMarker = jest.fn(); +const mockSetMarkerPauseStatus = jest.fn(); +const mockApplyMarks = jest.fn(); + +jest.mock( "@wordpress/data", () => ( { + dispatch: () => ( { + setActiveMarker: mockSetActiveMarker, + setMarkerPauseStatus: mockSetMarkerPauseStatus, + } ), +} ) ); + +jest.mock( "yoastseo", () => ( { + Paper: class { + /** + * @param {string} text The paper text. + * @param {Object} attributes The paper attributes. + */ + constructor( text, attributes ) { + this.text = text; + this.attributes = attributes; + } + }, +} ) ); + +global.YoastSEO = { analysis: { applyMarks: mockApplyMarks } }; + +import { resetMarks } from "../../src/elementor-v4/marks"; + +describe( "resetMarks", () => { + beforeEach( () => jest.clearAllMocks() ); + + it( "sets the active marker to null", () => { + resetMarks(); + expect( mockSetActiveMarker ).toHaveBeenCalledWith( null ); + } ); + + it( "unpauses the marker status", () => { + resetMarks(); + expect( mockSetMarkerPauseStatus ).toHaveBeenCalledWith( false ); + } ); + + it( "calls applyMarks with an empty Paper and empty marks array", () => { + resetMarks(); + expect( mockApplyMarks ).toHaveBeenCalledTimes( 1 ); + const [ , marks ] = mockApplyMarks.mock.calls[ 0 ]; + expect( marks ).toEqual( [] ); + } ); +} ); diff --git a/packages/yoastseo/spec/parse/build/private/filterTreeSpec.js b/packages/yoastseo/spec/parse/build/private/filterTreeSpec.js index f530da24680..d18cc26bf6b 100644 --- a/packages/yoastseo/spec/parse/build/private/filterTreeSpec.js +++ b/packages/yoastseo/spec/parse/build/private/filterTreeSpec.js @@ -310,6 +310,50 @@ describe( "Miscellaneous tests", () => { filteredTree = filterTree( tree, permanentFilters ); expect( filteredTree.findAll( child => child.attributes && child.attributes.id === "breadcrumbs" ) ).toHaveLength( 0 ); } ); + + it( "should filter out the Elementor table-of-contents widget", () => { + // Elementor's own ToC widget (distinct from the Yoast block) is navigation, not content. + const html = "
" + + "
" + + "Table of Contents
" + + "

This is the first sentence.

"; + const tree = adapt( parseFragment( html, { sourceCodeLocationInfo: true } ) ); + expect( tree.findAll( child => child.attributes && child.attributes.class && child.attributes.class.has( "elementor-widget-table-of-contents" ) ) ).toHaveLength( 1 ); + + const filteredTree = filterTree( tree, permanentFilters ); + expect( filteredTree.findAll( child => child.attributes && child.attributes.class && child.attributes.class.has( "elementor-widget-table-of-contents" ) ) ).toHaveLength( 0 ); + } ); + + it( "should filter out Elementor V4 atomic button, divider and svg widgets", () => { + // Atomic widgets render as `
`. + const html = "" + + "

" + + "
" + + "

This is the first sentence.

"; + const tree = adapt( parseFragment( html, { sourceCodeLocationInfo: true } ) ); + const filteredTree = filterTree( tree, permanentFilters ); + + [ "elementor-widget-e-button", "elementor-widget-e-divider", "elementor-widget-e-svg" ].forEach( ( widgetClass ) => { + const matches = filteredTree.findAll( + child => child.attributes && child.attributes.class && child.attributes.class.has( widgetClass ) + ); + expect( matches ).toHaveLength( 0 ); + } ); + // The button label must not survive as analysable text. + expect( filteredTree.findAll( child => child.name === "a" ) ).toHaveLength( 0 ); + } ); + + it( "should filter out an Elementor V4 atomic form together with its nested fields", () => { + // Atomic forms render as a `
` element, so the existing `form` filter removes the whole subtree. + const html = "" + + "
" + + "

This is the first sentence.

"; + const tree = adapt( parseFragment( html, { sourceCodeLocationInfo: true } ) ); + const filteredTree = filterTree( tree, permanentFilters ); + + expect( filteredTree.findAll( child => [ "form", "label", "input", "button" ].includes( child.name ) ) ).toHaveLength( 0 ); + } ); } ); describe( "Tests filtered trees of a few Yoast blocks and of a made-up Yoast block", () => { diff --git a/packages/yoastseo/src/parse/build/private/alwaysFilterElements.js b/packages/yoastseo/src/parse/build/private/alwaysFilterElements.js index b94009cbc00..2532d8f44e7 100644 --- a/packages/yoastseo/src/parse/build/private/alwaysFilterElements.js +++ b/packages/yoastseo/src/parse/build/private/alwaysFilterElements.js @@ -19,11 +19,17 @@ const permanentFilters = [ // Filters for Elementor widgets elementHasID( "breadcrumbs" ), elementHasClass( "elementor-button-wrapper" ), + // Catches the classic button widget's in Elementor V4, where the elementor-button-wrapper + // div is absent and the anchor is the first rendered child of the widget wrapper. + elementHasClass( "elementor-button" ), elementHasClass( "elementor-divider" ), elementHasClass( "elementor-spacer" ), elementHasClass( "elementor-custom-embed" ), elementHasClass( "elementor-icon-wrapper" ), - elementHasClass( "elementor-icon-box-wrapper" ), + // Filter only the icon container inside the icon-box widget, not the wrapper that also holds + // the title and description text. The old elementor-icon-box-wrapper filter was too wide and + // silently removed the heading and description from analysis. + elementHasClass( "elementor-icon-box-icon" ), elementHasClass( "elementor-counter" ), elementHasClass( "elementor-progress-wrapper" ), // This element is used for the progress bar widget title. @@ -33,6 +39,14 @@ const permanentFilters = [ elementHasClass( "elementor-shortcode" ), elementHasClass( "elementor-menu-anchor" ), elementHasClass( "e-rating" ), + // Elementor's own table-of-contents widget (distinct from the Yoast block above). It is + // navigation rather than content, so it is excluded in both the classic and V4 editors. + elementHasClass( "elementor-widget-table-of-contents" ), + // Elementor V4 atomic widgets that are not body content. Atomic forms render as a `
` + // element and are already covered by the `form` filter below, including their nested fields. + elementHasClass( "elementor-widget-e-button" ), + elementHasClass( "elementor-widget-e-divider" ), + elementHasClass( "elementor-widget-e-svg" ), // Filters out HTML elements. /* Elements are filtered out when: they contain content outside of the author's control (incl. quotes and embedded content); their content isn't natural language (e.g. code); they contain metadata hidden from the page visitor diff --git a/src/integrations/third-party/elementor.php b/src/integrations/third-party/elementor.php index eae64489f3b..9987020177d 100644 --- a/src/integrations/third-party/elementor.php +++ b/src/integrations/third-party/elementor.php @@ -2,6 +2,7 @@ namespace Yoast\WP\SEO\Integrations\Third_Party; +use Elementor\Plugin; use WP_Post; use WP_Screen; use WPSEO_Admin_Asset_Manager; @@ -421,6 +422,12 @@ public function enqueue() { $this->asset_manager->enqueue_script( 'admin-global' ); $this->asset_manager->enqueue_script( 'elementor' ); + $is_v4_atomic = $this->is_elementor_v4_atomic_active(); + + if ( $is_v4_atomic ) { + $this->asset_manager->enqueue_script( 'elementor-v4' ); + } + $this->asset_manager->localize_script( 'elementor', 'wpseoAdminGlobalL10n', \YoastSEO()->helpers->wincher->get_admin_global_links() ); $this->asset_manager->localize_script( 'elementor', 'wpseoAdminL10n', WPSEO_Utils::get_admin_l10n() ); $this->asset_manager->localize_script( 'elementor', 'wpseoFeaturesL10n', WPSEO_Utils::retrieve_enabled_features() ); @@ -459,6 +466,7 @@ public function enqueue() { 'isBlockEditor' => WP_Screen::get()->is_block_editor(), 'isElementorEditor' => true, 'isAlwaysIntroductionV2' => $this->is_elementor_version_compatible_with_introduction_v2(), + 'isElementorV4Atomic' => $is_v4_atomic, 'postStatus' => \get_post_status( $post_id ), 'postType' => \get_post_type( $post_id ), 'analysis' => [ @@ -503,6 +511,34 @@ private function is_elementor_version_compatible_with_introduction_v2(): bool { return \version_compare( $version, '3.30.0', '>=' ); } + /** + * Checks whether Elementor V4 (the atomic editor) is currently active on this site. + * + * Mirrors Elementor's own atomic check (`Atomic_Widgets\OptIn\Opt_In::EXPERIMENT_NAME`): + * the `e_opt_in_v4` experiment is the authoritative signal for the atomic editor and is + * only registered on Elementor versions that ship it, so `is_feature_active()` already + * returns false on older versions or partial installs. No separate version gate is used: + * the experiment is an opt-in to V4 that can be enabled before the major version bump, so + * a version comparison would wrongly suppress sites that opt in early. Defensive at each + * step so missing Elementor internals never throw. + * + * @return bool Whether the V4 atomic editor path should be used. + */ + private function is_elementor_v4_atomic_active(): bool { + if ( ! \class_exists( '\Elementor\Plugin' ) ) { + return false; + } + $elementor = ( Plugin::$instance ?? null ); + if ( $elementor === null || ! isset( $elementor->experiments ) ) { + return false; + } + if ( ! \method_exists( $elementor->experiments, 'is_feature_active' ) ) { + return false; + } + + return (bool) $elementor->experiments->is_feature_active( 'e_opt_in_v4' ); + } + /** * Renders the metabox hidden fields. * diff --git a/tests/Unit/Integrations/Third_Party/Elementor_Test.php b/tests/Unit/Integrations/Third_Party/Elementor_Test.php new file mode 100644 index 00000000000..ca322df4229 --- /dev/null +++ b/tests/Unit/Integrations/Third_Party/Elementor_Test.php @@ -0,0 +1,39 @@ +newInstanceWithoutConstructor(); + $method = $reflection->getMethod( 'is_elementor_v4_atomic_active' ); + $method->setAccessible( true ); + + self::assertFalse( $method->invoke( $instance ) ); + } +}