-
Notifications
You must be signed in to change notification settings - Fork 964
Add Elementor V4 atomic editor content extraction #23344
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 f8e239b
fix(elementor-v4): read image src and alt from htmlCache
vraja-pro f99325a
fix(elementor-v4): extract classic text-editor widget content for lin…
vraja-pro b03c076
test(elementor-v4): cover internal link extraction from text-editor w…
vraja-pro 76a1e0d
fix(elementor-v4): read image src/alt from preview DOM for newly adde…
vraja-pro 68892fd
refactor(elementor-v4): split initialize.js into focused modules
vraja-pro ba8c3df
refactor: change import
vraja-pro 6be617a
fix(elementor-v4): read live container model and listen to editor cha…
vraja-pro 6f5d00e
test(elementor-v4): split content-walker tests into focused files
vraja-pro 82e805a
test(elementor-v4): add unit tests for document-tree, editor-data, ma…
vraja-pro dc89668
fix(elementor-v4): fix linting
vraja-pro 3b92709
fix: adds elementor dependecies elementor-v4
vraja-pro 911cef3
fix: removes elementor v4 stylesheet registration
vraja-pro 2cf8919
fix: php cs
vraja-pro 7f78c7d
refactor(elementor-v4): refactors, cleanup adds comments
vraja-pro a2a7cbf
fix(elementor-v4): wrap widget output in <a> when a widget-level link…
vraja-pro 197735a
fix(elementor-v4): update editor-data tests for single-step DOM selector
vraja-pro b9bd670
fix(elementor-v4): fix image DOM selector and always prefer live DOM …
vraja-pro bed778b
fix(elementor-v4): strip newlines and tabs from extracted content
vraja-pro 5b0b49f
feat(elementor-v4): exclude button and rating widgets from analysis
vraja-pro 2b01e75
fix(elementor-v4): unwrap Backbone collections in walkAtomicTree
vraja-pro 424209e
refactor(elementor-v4): reduce complexity in content-walker and add p…
vraja-pro 8d295eb
refactor(content-walker): streamline widget HTML extraction and posit…
FAMarfuaty 6f90f18
refactor(elementor-v4): simplify atomic editor activation check
FAMarfuaty 35fcb39
fix(elementor-v4): enhance content extraction with MutationObserver f…
FAMarfuaty d6c23c3
refactor(content-walker): standardize terminology and improve documen…
FAMarfuaty 64ae41a
test(elementor-v4): add tests for widget recursion and atomic gate be…
FAMarfuaty fa52ef7
test(elementor-v4): add tests for first-load content observer functio…
FAMarfuaty fb4fcab
fix(yoastseo): narrow icon-box filter to exclude only the icon, not t…
vraja-pro e0bfd5d
fix(yoastseo): exclude classic Elementor button text from analysis in V4
vraja-pro File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" ), | ||
| }; | ||
| }; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.